def test_7b254d88f122_moderation_action(self): mailinglist_table = sa.sql.table( # noqa 'mailinglist', sa.sql.column('id', sa.Integer), sa.sql.column('list_id', sa.Unicode), sa.sql.column('default_member_action', Enum(Action)), sa.sql.column('default_nonmember_action', Enum(Action)), ) member_table = sa.sql.table( 'member', sa.sql.column('id', sa.Integer), sa.sql.column('list_id', sa.Unicode), sa.sql.column('address_id', sa.Integer), sa.sql.column('role', Enum(MemberRole)), sa.sql.column('moderation_action', Enum(Action)), ) user_manager = getUtility(IUserManager) with transaction(): # Start at the previous revision. alembic.command.downgrade(alembic_cfg, 'd4fbb4fd34ca') # Create a mailing list through the standard API. ant = create_list('*****@*****.**') # Create some members. anne = user_manager.create_address('*****@*****.**') bart = user_manager.create_address('*****@*****.**') cris = user_manager.create_address('*****@*****.**') dana = user_manager.create_address('*****@*****.**') # Flush the database to get the last auto-increment id. config.db.store.flush() # Assign some moderation actions to the members created above. config.db.store.execute(member_table.insert().values([ {'address_id': anne.id, 'role': MemberRole.owner, 'list_id': ant.list_id, 'moderation_action': Action.accept}, {'address_id': bart.id, 'role': MemberRole.moderator, 'list_id': ant.list_id, 'moderation_action': Action.accept}, {'address_id': cris.id, 'role': MemberRole.member, 'list_id': ant.list_id, 'moderation_action': Action.defer}, {'address_id': dana.id, 'role': MemberRole.nonmember, 'list_id': ant.list_id, 'moderation_action': Action.hold}, ])) # Cris and Dana have actions which match the list default action for # members and nonmembers respectively. self.assertEqual( ant.members.get_member('*****@*****.**').moderation_action, ant.default_member_action) self.assertEqual( ant.nonmembers.get_member('*****@*****.**').moderation_action, ant.default_nonmember_action) # Upgrade and check the moderation_actions. Cris's and Dana's # actions have been set to None to fall back to the list defaults. alembic.command.upgrade(alembic_cfg, '7b254d88f122') members = config.db.store.execute(sa.select([ member_table.c.address_id, member_table.c.moderation_action, ])).fetchall() self.assertEqual(members, [ (anne.id, Action.accept), (bart.id, Action.accept), (cris.id, None), (dana.id, None), ]) # Downgrade and check that Cris's and Dana's actions have been set # explicitly. alembic.command.downgrade(alembic_cfg, 'd4fbb4fd34ca') members = config.db.store.execute(sa.select([ member_table.c.address_id, member_table.c.moderation_action, ])).fetchall() self.assertEqual(members, [ (anne.id, Action.accept), (bart.id, Action.accept), (cris.id, Action.defer), (dana.id, Action.hold), ])
class Member(Model): """See `IMember`.""" __tablename__ = 'member' id = Column(Integer, primary_key=True) _member_id = Column(UUID) role = Column(Enum(MemberRole), index=True) list_id = Column(SAUnicode, index=True) moderation_action = Column(Enum(Action)) address_id = Column(Integer, ForeignKey('address.id'), index=True) _address = relationship('Address') preferences_id = Column(Integer, ForeignKey('preferences.id'), index=True) preferences = relationship('Preferences') user_id = Column(Integer, ForeignKey('user.id'), index=True) _user = relationship('User') def __init__(self, role, list_id, subscriber): self._member_id = uid_factory.new() self.role = role self.list_id = list_id if IAddress.providedBy(subscriber): self._address = subscriber # Look this up dynamically. self._user = None elif IUser.providedBy(subscriber): self._user = subscriber # Look this up dynamically. self._address = None else: raise ValueError('subscriber must be a user or address') if role in (MemberRole.owner, MemberRole.moderator): self.moderation_action = Action.accept else: assert role in (MemberRole.member, MemberRole.nonmember), ( 'Invalid MemberRole: {}'.format(role)) self.moderation_action = None def __repr__(self): return '<Member: {} on {} as {}>'.format( self.address, self.mailing_list.fqdn_listname, self.role) @property def mailing_list(self): """See `IMember`.""" list_manager = getUtility(IListManager) return list_manager.get_by_list_id(self.list_id) @property def member_id(self): """See `IMember`.""" return self._member_id @property def address(self): """See `IMember`.""" return (self._user.preferred_address if self._address is None else self._address) @address.setter def address(self, new_address): """See `IMember`.""" if self._address is None: # XXX Either we need a better exception here, or we should allow # changing a subscription from preferred address to explicit # address (and vice versa via del'ing the .address attribute. raise MembershipError('Membership is via preferred address') if new_address.verified_on is None: # A member cannot change their subscription address to an # unverified address. raise UnverifiedAddressError('Unverified address') user = getUtility(IUserManager).get_user(new_address.email) if user is None or user != self.user: raise MembershipError('Address is not controlled by user') self._address = new_address @property def user(self): """See `IMember`.""" return (self._user if self._address is None else getUtility(IUserManager).get_user(self._address.email)) @property def subscriber(self): return (self._user if self._address is None else self._address) @property def display_name(self): # Try to find a non-empty display name. We first look at the directly # subscribed record, which will either be the address or the user. # That's handled automatically by going through member.subscriber. If # that doesn't give us something useful, try whatever user is linked # to the subscriber. if self.subscriber.display_name: return self.subscriber.display_name # If an unlinked address is subscribed there will be no .user. elif self.user is not None and self.user.display_name: return self.user.display_name else: return '' def _lookup(self, preference, default=None): pref = getattr(self.preferences, preference) if pref is not None: return pref pref = getattr(self.address.preferences, preference) if pref is not None: return pref if self.address.user: pref = getattr(self.address.user.preferences, preference) if pref is not None: return pref if default is None: return getattr(system_preferences, preference) return default @property def acknowledge_posts(self): """See `IMember`.""" return self._lookup('acknowledge_posts') @property def preferred_language(self): """See `IMember`.""" missing = object() language = self._lookup('preferred_language', missing) return (self.mailing_list.preferred_language if language is missing else language) @property def receive_list_copy(self): """See `IMember`.""" return self._lookup('receive_list_copy') @property def receive_own_postings(self): """See `IMember`.""" return self._lookup('receive_own_postings') @property def delivery_mode(self): """See `IMember`.""" return self._lookup('delivery_mode') @property def delivery_status(self): """See `IMember`.""" return self._lookup('delivery_status') @dbconnection def unsubscribe(self, store): """See `IMember`.""" # Yes, this must get triggered before self is deleted. notify(UnsubscriptionEvent(self.mailing_list, self)) store.delete(self.preferences) store.delete(self)
import sqlalchemy as sa from alembic import op from mailman.database.types import Enum from mailman.interfaces.action import Action from mailman.interfaces.member import MemberRole # Revision identifiers, used by Alembic. revision = '7b254d88f122' down_revision = 'd4fbb4fd34ca' mailinglist_table = sa.sql.table( 'mailinglist', sa.sql.column('id', sa.Integer), sa.sql.column('list_id', sa.Unicode), sa.sql.column('default_member_action', Enum(Action)), sa.sql.column('default_nonmember_action', Enum(Action)), ) member_table = sa.sql.table( 'member', sa.sql.column('id', sa.Integer), sa.sql.column('list_id', sa.Unicode), sa.sql.column('role', Enum(MemberRole)), sa.sql.column('moderation_action', Enum(Action)), ) # This migration only considers members and nonmembers. members_query = member_table.select().where( sa.or_( member_table.c.role == MemberRole.member,
class MailingList(Model): """See `IMailingList`.""" __tablename__ = 'mailinglist' id = Column(Integer, primary_key=True) # XXX denotes attributes that should be part of the public interface but # are currently missing. # List identity list_name = Column(SAUnicode, index=True) mail_host = Column(SAUnicode, index=True) _list_id = Column('list_id', SAUnicode, index=True, unique=True) allow_list_posts = Column(Boolean) include_rfc2369_headers = Column(Boolean) advertised = Column(Boolean) anonymous_list = Column(Boolean) # Attributes not directly modifiable via the web u/i created_at = Column(DateTime) # Attributes which are directly modifiable via the web u/i. The more # complicated attributes are currently stored as pickles, though that # will change as the schema and implementation is developed. next_request_id = Column(Integer) next_digest_number = Column(Integer) digest_last_sent_at = Column(DateTime) volume = Column(Integer) last_post_at = Column(DateTime) # Attributes which are directly modifiable via the web u/i. The more # complicated attributes are currently stored as pickles, though that # will change as the schema and implementation is developed. accept_these_nonmembers = Column(MutableList.as_mutable(PickleType)) # XXX admin_immed_notify = Column(Boolean) admin_notify_mchanges = Column(Boolean) administrivia = Column(Boolean) archive_policy = Column(Enum(ArchivePolicy)) # Automatic responses. autoresponse_grace_period = Column(Interval) autorespond_owner = Column(Enum(ResponseAction)) autoresponse_owner_text = Column(SAUnicode) autorespond_postings = Column(Enum(ResponseAction)) autoresponse_postings_text = Column(SAUnicode) autorespond_requests = Column(Enum(ResponseAction)) autoresponse_request_text = Column(SAUnicode) # Content filters. filter_action = Column(Enum(FilterAction)) filter_content = Column(Boolean) collapse_alternatives = Column(Boolean) convert_html_to_plaintext = Column(Boolean) # Bounces. bounce_info_stale_after = Column(Interval) bounce_matching_headers = Column(SAUnicode) # XXX bounce_notify_owner_on_disable = Column(Boolean) bounce_notify_owner_on_removal = Column(Boolean) bounce_score_threshold = Column(Integer) bounce_you_are_disabled_warnings = Column(Integer) bounce_you_are_disabled_warnings_interval = Column(Interval) forward_unrecognized_bounces_to = Column( Enum(UnrecognizedBounceDisposition)) process_bounces = Column(Boolean) # DMARC dmarc_mitigate_action = Column(Enum(DMARCMitigateAction)) dmarc_mitigate_unconditionally = Column(Boolean) dmarc_moderation_notice = Column(SAUnicodeLarge) dmarc_wrapped_message_text = Column(SAUnicodeLarge) # Miscellaneous default_member_action = Column(Enum(Action)) default_nonmember_action = Column(Enum(Action)) description = Column(SAUnicode) digests_enabled = Column(Boolean) digest_is_default = Column(Boolean) digest_send_periodic = Column(Boolean) digest_size_threshold = Column(Float) digest_volume_frequency = Column(Enum(DigestFrequency)) discard_these_nonmembers = Column(MutableList.as_mutable(PickleType)) emergency = Column(Boolean) encode_ascii_prefixes = Column(Boolean) first_strip_reply_to = Column(Boolean) forward_auto_discards = Column(Boolean) gateway_to_mail = Column(Boolean) gateway_to_news = Column(Boolean) hold_these_nonmembers = Column(MutableList.as_mutable(PickleType)) info = Column(SAUnicode) linked_newsgroup = Column(SAUnicode) max_days_to_hold = Column(Integer) max_message_size = Column(Integer) max_num_recipients = Column(Integer) member_moderation_notice = Column(SAUnicode) # FIXME: There should be no moderator_password moderator_password = Column(LargeBinary) # TODO : was RawStr() newsgroup_moderation = Column(Enum(NewsgroupModeration)) nntp_prefix_subject_too = Column(Boolean) nonmember_rejection_notice = Column(SAUnicode) obscure_addresses = Column(Boolean) owner_chain = Column(SAUnicode) owner_pipeline = Column(SAUnicode) personalize = Column(Enum(Personalization)) post_id = Column(Integer) posting_chain = Column(SAUnicode) posting_pipeline = Column(SAUnicode) _preferred_language = Column('preferred_language', SAUnicode) display_name = Column(SAUnicode) reject_these_nonmembers = Column(MutableList.as_mutable(PickleType)) reply_goes_to_list = Column(Enum(ReplyToMunging)) reply_to_address = Column(SAUnicode) require_explicit_destination = Column(Boolean) respond_to_post_requests = Column(Boolean) member_roster_visibility = Column(Enum(roster.RosterVisibility)) scrub_nondigest = Column(Boolean) send_goodbye_message = Column(Boolean) send_welcome_message = Column(Boolean) subject_prefix = Column(SAUnicode) subscription_policy = Column(Enum(SubscriptionPolicy)) topics = Column(PickleType) topics_bodylines_limit = Column(Integer) topics_enabled = Column(Boolean) unsubscription_policy = Column(Enum(SubscriptionPolicy)) usenet_watermark = Column(Integer) archive_rendering_mode = Column(Enum(ArchiveRenderingMode)) # ORM relationships. header_matches = relationship( 'HeaderMatch', backref='mailing_list', cascade="all, delete-orphan", order_by="HeaderMatch._position") def __init__(self, fqdn_listname): super().__init__() listname, at, hostname = fqdn_listname.partition('@') assert hostname, 'Bad list name: {0}'.format(fqdn_listname) self.list_name = listname self.mail_host = hostname self._list_id = '{0}.{1}'.format(listname, hostname) # For the pending database self.next_request_id = 1 # We need to set up the rosters. Normally, this method will get called # when the MailingList object is loaded from the database, but when the # constructor is called, SQLAlchemy's `load` event isn't triggered. # Thus we need to set up the rosters explicitly. self._post_load() makedirs(self.data_path) def _post_load(self, *args): # This hooks up to SQLAlchemy's `load` event. self.owners = roster.OwnerRoster(self) self.moderators = roster.ModeratorRoster(self) self.administrators = roster.AdministratorRoster(self) self.members = roster.MemberRoster(self) self.regular_members = roster.RegularMemberRoster(self) self.digest_members = roster.DigestMemberRoster(self) self.subscribers = roster.Subscribers(self) self.nonmembers = roster.NonmemberRoster(self) @classmethod def __declare_last__(cls): # SQLAlchemy special directive hook called after mappings are assumed # to be complete. Use this to connect the roster instance creation # method with the SA `load` event. listen(cls, 'load', cls._post_load) def __repr__(self): return '<mailing list "{}" at {:#x}>'.format( self.fqdn_listname, id(self)) @property def fqdn_listname(self): """See `IMailingList`.""" return '{}@{}'.format(self.list_name, self.mail_host) @property def list_id(self): """See `IMailingList`.""" return self._list_id @property def domain(self): """See `IMailingList`.""" return getUtility(IDomainManager)[self.mail_host] @property def data_path(self): """See `IMailingList`.""" return os.path.join(config.LIST_DATA_DIR, self.list_id) # IMailingListAddresses @property def posting_address(self): """See `IMailingList`.""" return self.fqdn_listname @property def no_reply_address(self): """See `IMailingList`.""" return '{}@{}'.format(config.mailman.noreply_address, self.mail_host) @property def owner_address(self): """See `IMailingList`.""" return '{}-owner@{}'.format(self.list_name, self.mail_host) @property def request_address(self): """See `IMailingList`.""" return '{}-request@{}'.format(self.list_name, self.mail_host) @property def bounces_address(self): """See `IMailingList`.""" return '{}-bounces@{}'.format(self.list_name, self.mail_host) @property def join_address(self): """See `IMailingList`.""" return '{}-join@{}'.format(self.list_name, self.mail_host) @property def leave_address(self): """See `IMailingList`.""" return '{}-leave@{}'.format(self.list_name, self.mail_host) @property def subscribe_address(self): """See `IMailingList`.""" return '{}-subscribe@{}'.format(self.list_name, self.mail_host) @property def unsubscribe_address(self): """See `IMailingList`.""" return '{}-unsubscribe@{}'.format(self.list_name, self.mail_host) def confirm_address(self, cookie): """See `IMailingList`.""" local_part = expand(config.mta.verp_confirm_format, self, dict( address='{}-confirm'.format(self.list_name), cookie=cookie)) return '{}@{}'.format(local_part, self.mail_host) @property def preferred_language(self): """See `IMailingList`.""" return getUtility(ILanguageManager)[self._preferred_language] @preferred_language.setter def preferred_language(self, language): """See `IMailingList`.""" # Accept both a language code and a `Language` instance. try: self._preferred_language = language.code except AttributeError: self._preferred_language = language @dbconnection def send_one_last_digest_to(self, store, address, delivery_mode): """See `IMailingList`.""" digest = OneLastDigest(self, address, delivery_mode) store.add(digest) @property @dbconnection def last_digest_recipients(self, store): """See `IMailingList`.""" results = store.query(OneLastDigest).filter( OneLastDigest.mailing_list == self) recipients = [(digest.address, digest.delivery_mode) for digest in results] results.delete() return recipients @property @dbconnection def filter_types(self, store): """See `IMailingList`.""" results = store.query(ContentFilter).filter( ContentFilter.mailing_list == self, ContentFilter.filter_type == FilterType.filter_mime) for content_filter in results: yield content_filter.filter_pattern @filter_types.setter @dbconnection def filter_types(self, store, sequence): """See `IMailingList`.""" # First, delete all existing MIME type filter patterns. results = store.query(ContentFilter).filter( ContentFilter.mailing_list == self, ContentFilter.filter_type == FilterType.filter_mime) results.delete() # Now add all the new filter types. for mime_type in sequence: content_filter = ContentFilter( self, mime_type, FilterType.filter_mime) store.add(content_filter) @property @dbconnection def pass_types(self, store): """See `IMailingList`.""" results = store.query(ContentFilter).filter( ContentFilter.mailing_list == self, ContentFilter.filter_type == FilterType.pass_mime) for content_filter in results: yield content_filter.filter_pattern @pass_types.setter @dbconnection def pass_types(self, store, sequence): """See `IMailingList`.""" # First, delete all existing MIME type pass patterns. results = store.query(ContentFilter).filter( ContentFilter.mailing_list == self, ContentFilter.filter_type == FilterType.pass_mime) results.delete() # Now add all the new filter types. for mime_type in sequence: content_filter = ContentFilter( self, mime_type, FilterType.pass_mime) store.add(content_filter) @property @dbconnection def filter_extensions(self, store): """See `IMailingList`.""" results = store.query(ContentFilter).filter( ContentFilter.mailing_list == self, ContentFilter.filter_type == FilterType.filter_extension) for content_filter in results: yield content_filter.filter_pattern @filter_extensions.setter @dbconnection def filter_extensions(self, store, sequence): """See `IMailingList`.""" # First, delete all existing file extensions filter patterns. results = store.query(ContentFilter).filter( ContentFilter.mailing_list == self, ContentFilter.filter_type == FilterType.filter_extension) results.delete() # Now add all the new filter types. for mime_type in sequence: content_filter = ContentFilter( self, mime_type, FilterType.filter_extension) store.add(content_filter) @property @dbconnection def pass_extensions(self, store): """See `IMailingList`.""" results = store.query(ContentFilter).filter( ContentFilter.mailing_list == self, ContentFilter.filter_type == FilterType.pass_extension) for content_filter in results: yield content_filter.filter_pattern @pass_extensions.setter @dbconnection def pass_extensions(self, store, sequence): """See `IMailingList`.""" # First, delete all existing file extensions pass patterns. results = store.query(ContentFilter).filter( ContentFilter.mailing_list == self, ContentFilter.filter_type == FilterType.pass_extension) results.delete() # Now add all the new filter types. for mime_type in sequence: content_filter = ContentFilter( self, mime_type, FilterType.pass_extension) store.add(content_filter) def get_roster(self, role): """See `IMailingList`.""" if role is MemberRole.member: return self.members elif role is MemberRole.owner: return self.owners elif role is MemberRole.moderator: return self.moderators elif role is MemberRole.nonmember: return self.nonmembers else: raise ValueError('Undefined MemberRole: {}'.format(role)) def _get_subscriber(self, store, subscriber, role): """Get some information about a user/address. Returns a 2-tuple of (member, email) for the given subscriber. If the subscriber is is not an ``IAddress`` or ``IUser``, then a 2-tuple of (None, None) is returned. If the subscriber is not already subscribed, then (None, email) is returned. If the subscriber is an ``IUser`` and does not have a preferred address, (member, None) is returned. """ member = None email = None if IAddress.providedBy(subscriber): member = store.query(Member).filter( Member.role == role, Member.list_id == self._list_id, Member._address == subscriber).first() email = subscriber.email elif IUser.providedBy(subscriber): if subscriber.preferred_address is None: raise MissingPreferredAddressError(subscriber) email = subscriber.preferred_address.email member = store.query(Member).filter( Member.role == role, Member.list_id == self._list_id, Member._user == subscriber).first() return member, email @dbconnection def is_subscribed(self, store, subscriber, role=MemberRole.member): """See `IMailingList`.""" member, email = self._get_subscriber(store, subscriber, role) return member is not None @dbconnection def subscribe(self, store, subscriber, role=MemberRole.member, send_welcome_message=None): """See `IMailingList`.""" member, email = self._get_subscriber(store, subscriber, role) test_email = email or subscriber.lower() # Allow list posting address only for nonmember role. if (test_email == self.posting_address and role != MemberRole.nonmember): raise InvalidEmailAddressError('List posting address not allowed') if member is not None: raise AlreadySubscribedError(self.fqdn_listname, email, role) if IBanManager(self).is_banned(test_email): raise MembershipIsBannedError(self, test_email) member = Member(role=role, list_id=self._list_id, subscriber=subscriber) member.preferences = Preferences() store.add(member) notify(SubscriptionEvent( self, member, send_welcome_message=send_welcome_message)) return member
class Member(Model): """See `IMember`.""" __tablename__ = 'member' id = Column(Integer, primary_key=True) _member_id = Column(UUID) role = Column(Enum(MemberRole)) list_id = Column(Unicode) moderation_action = Column(Enum(Action)) address_id = Column(Integer, ForeignKey('address.id')) _address = relationship('Address') preferences_id = Column(Integer, ForeignKey('preferences.id')) preferences = relationship('Preferences') user_id = Column(Integer, ForeignKey('user.id')) _user = relationship('User') def __init__(self, role, list_id, subscriber): self._member_id = uid_factory.new_uid() self.role = role self.list_id = list_id if IAddress.providedBy(subscriber): self._address = subscriber # Look this up dynamically. self._user = None elif IUser.providedBy(subscriber): self._user = subscriber # Look this up dynamically. self._address = None else: raise ValueError('subscriber must be a user or address') if role in (MemberRole.owner, MemberRole.moderator): self.moderation_action = Action.accept elif role is MemberRole.member: self.moderation_action = getUtility(IListManager).get_by_list_id( list_id).default_member_action else: assert role is MemberRole.nonmember, ( 'Invalid MemberRole: {0}'.format(role)) self.moderation_action = getUtility(IListManager).get_by_list_id( list_id).default_nonmember_action def __repr__(self): return '<Member: {0} on {1} as {2}>'.format( self.address, self.mailing_list.fqdn_listname, self.role) @property def mailing_list(self): """See `IMember`.""" list_manager = getUtility(IListManager) return list_manager.get_by_list_id(self.list_id) @property def member_id(self): """See `IMember`.""" return self._member_id @property def address(self): """See `IMember`.""" return (self._user.preferred_address if self._address is None else self._address) @address.setter def address(self, new_address): """See `IMember`.""" if self._address is None: # XXX Either we need a better exception here, or we should allow # changing a subscription from preferred address to explicit # address (and vice versa via del'ing the .address attribute. raise MembershipError('Membership is via preferred address') if new_address.verified_on is None: # A member cannot change their subscription address to an # unverified address. raise UnverifiedAddressError(new_address) user = getUtility(IUserManager).get_user(new_address.email) if user is None or user != self.user: raise MembershipError('Address is not controlled by user') self._address = new_address @property def user(self): """See `IMember`.""" return (self._user if self._address is None else getUtility(IUserManager).get_user(self._address.email)) @property def subscriber(self): return (self._user if self._address is None else self._address) def _lookup(self, preference, default=None): pref = getattr(self.preferences, preference) if pref is not None: return pref pref = getattr(self.address.preferences, preference) if pref is not None: return pref if self.address.user: pref = getattr(self.address.user.preferences, preference) if pref is not None: return pref if default is None: return getattr(system_preferences, preference) return default @property def acknowledge_posts(self): """See `IMember`.""" return self._lookup('acknowledge_posts') @property def preferred_language(self): """See `IMember`.""" missing = object() language = self._lookup('preferred_language', missing) if language is missing: language = ((self.mailing_list and self.mailing_list.preferred_language) or system_preferences.preferred_language) return language @property def receive_list_copy(self): """See `IMember`.""" return self._lookup('receive_list_copy') @property def receive_own_postings(self): """See `IMember`.""" return self._lookup('receive_own_postings') @property def delivery_mode(self): """See `IMember`.""" return self._lookup('delivery_mode') @property def delivery_status(self): """See `IMember`.""" return self._lookup('delivery_status') @property def options_url(self): """See `IMember`.""" # XXX Um, this is definitely wrong return 'http://example.com/' + self.address.email @dbconnection def unsubscribe(self, store): """See `IMember`.""" # Yes, this must get triggered before self is deleted. notify(UnsubscriptionEvent(self.mailing_list, self)) store.delete(self.preferences) store.delete(self)