Exemple #1
0
 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),
         ])
Exemple #2
0
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)
Exemple #3
0
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
Exemple #5
0
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)