• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / fx-private-relay / 25ea6f0c-2f8a-44be-adaa-3a8c425f3aaa

19 Jul 2024 04:55PM CUT coverage: 85.415% (-0.003%) from 85.418%
25ea6f0c-2f8a-44be-adaa-3a8c425f3aaa

push

circleci

web-flow
Merge pull request #4885 from mozilla/MPP-3720-MPP-3806

Added a user allow list to ignore abuse metrics, avoid race conditions in abuse metrics

4092 of 5241 branches covered (78.08%)

Branch coverage included in aggregate %.

32 of 33 new or added lines in 2 files covered. (96.97%)

2 existing lines in 2 files now uncovered.

15919 of 18187 relevant lines covered (87.53%)

10.46 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.36
/emails/models.py
1
from __future__ import annotations
1✔
2

3
import logging
1✔
4
import random
1✔
5
import string
1✔
6
import uuid
1✔
7
from collections import namedtuple
1✔
8
from collections.abc import Iterable
1✔
9
from datetime import UTC, datetime, timedelta
1✔
10
from hashlib import sha256
1✔
11
from typing import Any, Literal, cast
1✔
12

13
from django.conf import settings
1✔
14
from django.contrib.auth.models import User
1✔
15
from django.core.validators import MinLengthValidator
1✔
16
from django.db import models, transaction
1✔
17
from django.db.models.base import ModelBase
1✔
18
from django.db.models.query import QuerySet
1✔
19
from django.utils.translation.trans_real import (
1✔
20
    get_supported_language_variant,
21
    parse_accept_lang_header,
22
)
23

24
from allauth.socialaccount.models import SocialAccount
1✔
25

26
from privaterelay.plans import get_premium_countries
1✔
27
from privaterelay.utils import (
1✔
28
    AcceptLanguageError,
29
    flag_is_active_in_task,
30
    guess_country_from_accept_lang,
31
)
32

33
from .exceptions import (
1✔
34
    CannotMakeSubdomainException,
35
    DomainAddrDuplicateException,
36
    DomainAddrUnavailableException,
37
    DomainAddrUpdateException,
38
)
39
from .utils import get_domains_from_settings, incr_if_enabled
1✔
40
from .validators import (
1✔
41
    check_user_can_make_another_address,
42
    check_user_can_make_domain_address,
43
    is_blocklisted,
44
    valid_address,
45
    valid_available_subdomain,
46
)
47

48
if settings.PHONES_ENABLED:
1!
49
    from phones.models import RealPhone, RelayNumber
1✔
50

51

52
logger = logging.getLogger("events")
1✔
53
abuse_logger = logging.getLogger("abusemetrics")
1✔
54

55
BounceStatus = namedtuple("BounceStatus", "paused type")
1✔
56

57
DOMAIN_CHOICES = [(1, "RELAY_FIREFOX_DOMAIN"), (2, "MOZMAIL_DOMAIN")]
1✔
58
PREMIUM_DOMAINS = ["mozilla.com", "getpocket.com", "mozillafoundation.org"]
1✔
59

60

61
def default_server_storage() -> bool:
1✔
62
    """
63
    This historical function is referenced in migration
64
    0029_profile_add_deleted_metric_and_changeserver_storage_default
65
    """
66
    return True
×
67

68

69
def default_domain_numerical() -> int:
1✔
70
    """Return the default value for RelayAddress.domain"""
71
    domains = get_domains_from_settings()
1✔
72
    domain = domains["MOZMAIL_DOMAIN"]
1✔
73
    return get_domain_numerical(domain)
1✔
74

75

76
def get_domain_numerical(domain_address: str) -> int:
1✔
77
    """Turn a domain name into a numerical domain"""
78
    # get domain name from the address
79
    domains = get_domains_from_settings()
1✔
80
    domains_keys = list(domains.keys())
1✔
81
    domains_values = list(domains.values())
1✔
82
    domain_name = domains_keys[domains_values.index(domain_address)]
1✔
83
    # get domain numerical value from domain name
84
    choices = dict(DOMAIN_CHOICES)
1✔
85
    choices_keys = list(choices.keys())
1✔
86
    choices_values = list(choices.values())
1✔
87
    return choices_keys[choices_values.index(domain_name)]
1✔
88

89

90
def address_hash(
1✔
91
    address: str, subdomain: str | None = None, domain: str | None = None
92
) -> str:
93
    """Create a hash of a Relay address, to prevent re-use."""
94
    if not domain:
1✔
95
        domain = get_domains_from_settings()["MOZMAIL_DOMAIN"]
1✔
96
    if subdomain:
1✔
97
        return sha256(f"{address}@{subdomain}.{domain}".encode()).hexdigest()
1✔
98
    if domain == settings.RELAY_FIREFOX_DOMAIN:
1✔
99
        return sha256(f"{address}".encode()).hexdigest()
1✔
100
    return sha256(f"{address}@{domain}".encode()).hexdigest()
1✔
101

102

103
def address_default() -> str:
1✔
104
    """Return a random value for RelayAddress.address"""
105
    return "".join(
1✔
106
        random.choices(  # noqa: S311 (standard pseudo-random generator used)
107
            string.ascii_lowercase + string.digits, k=9
108
        )
109
    )
110

111

112
def hash_subdomain(subdomain: str, domain: str = settings.MOZMAIL_DOMAIN) -> str:
1✔
113
    return sha256(f"{subdomain}.{domain}".encode()).hexdigest()
1✔
114

115

116
class Profile(models.Model):
1✔
117
    user = models.OneToOneField(User, on_delete=models.CASCADE)
1✔
118
    api_token = models.UUIDField(default=uuid.uuid4)
1✔
119
    num_address_deleted = models.PositiveIntegerField(default=0)
1✔
120
    date_subscribed = models.DateTimeField(blank=True, null=True)
1✔
121
    date_subscribed_phone = models.DateTimeField(blank=True, null=True)
1✔
122
    # TODO MPP-2972: delete date_phone_subscription_checked in favor of
123
    # date_phone_subscription_next_reset
124
    date_phone_subscription_checked = models.DateTimeField(blank=True, null=True)
1✔
125
    date_phone_subscription_start = models.DateTimeField(blank=True, null=True)
1✔
126
    date_phone_subscription_reset = models.DateTimeField(blank=True, null=True)
1✔
127
    date_phone_subscription_end = models.DateTimeField(blank=True, null=True)
1✔
128
    address_last_deleted = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
129
    last_soft_bounce = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
130
    last_hard_bounce = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
131
    last_account_flagged = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
132
    num_deleted_relay_addresses = models.PositiveIntegerField(default=0)
1✔
133
    num_deleted_domain_addresses = models.PositiveIntegerField(default=0)
1✔
134
    num_email_forwarded_in_deleted_address = models.PositiveIntegerField(default=0)
1✔
135
    num_email_blocked_in_deleted_address = models.PositiveIntegerField(default=0)
1✔
136
    num_level_one_trackers_blocked_in_deleted_address = models.PositiveIntegerField(
1✔
137
        default=0, null=True
138
    )
139
    num_email_replied_in_deleted_address = models.PositiveIntegerField(default=0)
1✔
140
    num_email_spam_in_deleted_address = models.PositiveIntegerField(default=0)
1✔
141
    subdomain = models.CharField(
1✔
142
        blank=True,
143
        null=True,
144
        unique=True,
145
        max_length=63,
146
        db_index=True,
147
        validators=[valid_available_subdomain],
148
    )
149
    # Whether we store the user's alias labels in the server
150
    server_storage = models.BooleanField(default=True)
1✔
151
    # Whether we store the caller/sender log for the user's relay number
152
    store_phone_log = models.BooleanField(default=True)
1✔
153
    # TODO: Data migration to set null to false
154
    # TODO: Schema migration to remove null=True
155
    remove_level_one_email_trackers = models.BooleanField(null=True, default=False)
1✔
156
    onboarding_state = models.PositiveIntegerField(default=0)
1✔
157
    onboarding_free_state = models.PositiveIntegerField(default=0)
1✔
158
    auto_block_spam = models.BooleanField(default=False)
1✔
159
    forwarded_first_reply = models.BooleanField(default=False)
1✔
160
    # Empty string means the profile was created through relying party flow
161
    created_by = models.CharField(blank=True, null=True, max_length=63)
1✔
162
    sent_welcome_email = models.BooleanField(default=False)
1✔
163
    last_engagement = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
164

165
    def __str__(self):
1✔
166
        return f"{self.user} Profile"
1✔
167

168
    def save(
1✔
169
        self,
170
        force_insert: bool | tuple[ModelBase, ...] = False,
171
        force_update: bool = False,
172
        using: str | None = None,
173
        update_fields: Iterable[str] | None = None,
174
    ) -> None:
175
        # always lower-case the subdomain before saving it
176
        # TODO: change subdomain field as a custom field inheriting from
177
        # CharField to validate constraints on the field update too
178
        if self.subdomain and not self.subdomain.islower():
1✔
179
            self.subdomain = self.subdomain.lower()
1✔
180
            if update_fields is not None:
1✔
181
                update_fields = {"subdomain"}.union(update_fields)
1✔
182
        super().save(
1✔
183
            force_insert=force_insert,
184
            force_update=force_update,
185
            using=using,
186
            update_fields=update_fields,
187
        )
188
        # any time a profile is saved with server_storage False, delete the
189
        # appropriate server-stored Relay address data.
190
        if not self.server_storage:
1✔
191
            relay_addresses = RelayAddress.objects.filter(user=self.user)
1✔
192
            relay_addresses.update(description="", generated_for="", used_on="")
1✔
193
            domain_addresses = DomainAddress.objects.filter(user=self.user)
1✔
194
            domain_addresses.update(description="", used_on="")
1✔
195
        if settings.PHONES_ENABLED:
1!
196
            # any time a profile is saved with store_phone_log False, delete the
197
            # appropriate server-stored InboundContact records
198
            from phones.models import InboundContact, RelayNumber
1✔
199

200
            if not self.store_phone_log:
1✔
201
                try:
1✔
202
                    relay_number = RelayNumber.objects.get(user=self.user)
1✔
203
                    InboundContact.objects.filter(relay_number=relay_number).delete()
1✔
204
                except RelayNumber.DoesNotExist:
1✔
205
                    pass
1✔
206

207
    @property
1✔
208
    def language(self):
1✔
209
        if self.fxa and self.fxa.extra_data.get("locale"):
1✔
210
            for accept_lang, _ in parse_accept_lang_header(
1!
211
                self.fxa.extra_data.get("locale")
212
            ):
213
                try:
1✔
214
                    return get_supported_language_variant(accept_lang)
1✔
215
                except LookupError:
×
216
                    continue
×
217
        return "en"
1✔
218

219
    # This method returns whether the locale associated with the user's Mozilla account
220
    # includes a country code from a Premium country. This is less accurate than using
221
    # get_countries_info_from_request_and_mapping(), which can use a GeoIP lookup, so
222
    # prefer using that if a request context is available. In other contexts, for
223
    # example when sending an email, this method can be useful.
224
    @property
1✔
225
    def fxa_locale_in_premium_country(self) -> bool:
1✔
226
        if self.fxa and self.fxa.extra_data.get("locale"):
1✔
227
            try:
1✔
228
                country = guess_country_from_accept_lang(self.fxa.extra_data["locale"])
1✔
229
            except AcceptLanguageError:
1✔
230
                return False
1✔
231
            premium_countries = get_premium_countries()
1✔
232
            if country in premium_countries:
1✔
233
                return True
1✔
234
        return False
1✔
235

236
    @property
1✔
237
    def avatar(self) -> str | None:
1✔
238
        if fxa := self.fxa:
1!
239
            return str(fxa.extra_data.get("avatar"))
1✔
240
        return None
×
241

242
    @property
1✔
243
    def relay_addresses(self) -> QuerySet[RelayAddress]:
1✔
244
        return RelayAddress.objects.filter(user=self.user)
1✔
245

246
    @property
1✔
247
    def domain_addresses(self) -> QuerySet[DomainAddress]:
1✔
248
        return DomainAddress.objects.filter(user=self.user)
1✔
249

250
    @property
1✔
251
    def total_masks(self) -> int:
1✔
252
        ra_count: int = self.relay_addresses.count()
1✔
253
        da_count: int = self.domain_addresses.count()
1✔
254
        return ra_count + da_count
1✔
255

256
    @property
1✔
257
    def at_mask_limit(self) -> bool:
1✔
258
        if self.has_premium:
1✔
259
            return False
1✔
260
        ra_count: int = self.relay_addresses.count()
1✔
261
        return ra_count >= settings.MAX_NUM_FREE_ALIASES
1✔
262

263
    def check_bounce_pause(self) -> BounceStatus:
1✔
264
        if self.last_hard_bounce:
1✔
265
            last_hard_bounce_allowed = datetime.now(UTC) - timedelta(
1✔
266
                days=settings.HARD_BOUNCE_ALLOWED_DAYS
267
            )
268
            if self.last_hard_bounce > last_hard_bounce_allowed:
1✔
269
                return BounceStatus(True, "hard")
1✔
270
            self.last_hard_bounce = None
1✔
271
            self.save()
1✔
272
        if self.last_soft_bounce:
1✔
273
            last_soft_bounce_allowed = datetime.now(UTC) - timedelta(
1✔
274
                days=settings.SOFT_BOUNCE_ALLOWED_DAYS
275
            )
276
            if self.last_soft_bounce > last_soft_bounce_allowed:
1✔
277
                return BounceStatus(True, "soft")
1✔
278
            self.last_soft_bounce = None
1✔
279
            self.save()
1✔
280
        return BounceStatus(False, "")
1✔
281

282
    @property
1✔
283
    def bounce_status(self) -> BounceStatus:
1✔
284
        return self.check_bounce_pause()
1✔
285

286
    @property
1✔
287
    def next_email_try(self) -> datetime:
1✔
288
        bounce_pause, bounce_type = self.check_bounce_pause()
1✔
289

290
        if not bounce_pause:
1✔
291
            return datetime.now(UTC)
1✔
292

293
        if bounce_type == "soft":
1✔
294
            if not self.last_soft_bounce:
1!
295
                raise ValueError("self.last_soft_bounce must be truthy value.")
×
296
            return self.last_soft_bounce + timedelta(
1✔
297
                days=settings.SOFT_BOUNCE_ALLOWED_DAYS
298
            )
299

300
        if bounce_type != "hard":
1!
301
            raise ValueError("bounce_type must be either 'soft' or 'hard'")
×
302
        if not self.last_hard_bounce:
1!
303
            raise ValueError("self.last_hard_bounce must be truthy value.")
×
304
        return self.last_hard_bounce + timedelta(days=settings.HARD_BOUNCE_ALLOWED_DAYS)
1✔
305

306
    @property
1✔
307
    def last_bounce_date(self):
1✔
308
        if self.last_hard_bounce:
1✔
309
            return self.last_hard_bounce
1✔
310
        if self.last_soft_bounce:
1✔
311
            return self.last_soft_bounce
1✔
312
        return None
1✔
313

314
    @property
1✔
315
    def at_max_free_aliases(self) -> bool:
1✔
316
        relay_addresses_count: int = self.relay_addresses.count()
1✔
317
        return relay_addresses_count >= settings.MAX_NUM_FREE_ALIASES
1✔
318

319
    @property
1✔
320
    def fxa(self) -> SocialAccount | None:
1✔
321
        # Note: we are NOT using .filter() here because it invalidates
322
        # any profile instances that were queried with prefetch_related, which
323
        # we use in at least the profile view to minimize queries
324
        if not hasattr(self.user, "socialaccount_set"):
1!
325
            raise AttributeError("self.user must have socialaccount_set attribute")
×
326
        for sa in self.user.socialaccount_set.all():
1✔
327
            if sa.provider == "fxa":
1!
328
                return sa
1✔
329
        return None
1✔
330

331
    @property
1✔
332
    def display_name(self) -> str | None:
1✔
333
        # if display name is not set on FxA the
334
        # displayName key will not exist on the extra_data
335
        if fxa := self.fxa:
1!
336
            name = fxa.extra_data.get("displayName")
1✔
337
            return name if name is None else str(name)
1✔
338
        return None
×
339

340
    @property
1✔
341
    def custom_domain(self) -> str:
1✔
342
        if not self.subdomain:
×
343
            raise ValueError("self.subdomain must be truthy value.")
×
344
        return f"@{self.subdomain}.{settings.MOZMAIL_DOMAIN}"
×
345

346
    @property
1✔
347
    def has_premium(self) -> bool:
1✔
348
        if not self.user.is_active:
1!
349
            return False
×
350

351
        # FIXME: as we don't have all the tiers defined we are over-defining
352
        # this to mark the user as a premium user as well
353
        if not self.fxa:
1✔
354
            return False
1✔
355
        for premium_domain in PREMIUM_DOMAINS:
1✔
356
            if self.user.email.endswith(f"@{premium_domain}"):
1!
357
                return True
×
358
        user_subscriptions = self.fxa.extra_data.get("subscriptions", [])
1✔
359
        for sub in settings.SUBSCRIPTIONS_WITH_UNLIMITED:
1✔
360
            if sub in user_subscriptions:
1✔
361
                return True
1✔
362
        return False
1✔
363

364
    @property
1✔
365
    def has_phone(self) -> bool:
1✔
366
        if not self.fxa:
1✔
367
            return False
1✔
368
        if settings.RELAY_CHANNEL != "prod" and not settings.IN_PYTEST:
1!
369
            if not flag_is_active_in_task("phones", self.user):
×
370
                return False
×
371
        if flag_is_active_in_task("free_phones", self.user):
1!
372
            return True
×
373
        user_subscriptions = self.fxa.extra_data.get("subscriptions", [])
1✔
374
        for sub in settings.SUBSCRIPTIONS_WITH_PHONE:
1✔
375
            if sub in user_subscriptions:
1✔
376
                return True
1✔
377
        return False
1✔
378

379
    @property
1✔
380
    def has_vpn(self) -> bool:
1✔
381
        if not self.fxa:
1!
382
            return False
×
383
        user_subscriptions = self.fxa.extra_data.get("subscriptions", [])
1✔
384
        for sub in settings.SUBSCRIPTIONS_WITH_VPN:
1✔
385
            if sub in user_subscriptions:
1✔
386
                return True
1✔
387
        return False
1✔
388

389
    @property
1✔
390
    def emails_forwarded(self) -> int:
1✔
391
        return (
1✔
392
            sum(ra.num_forwarded for ra in self.relay_addresses)
393
            + sum(da.num_forwarded for da in self.domain_addresses)
394
            + self.num_email_forwarded_in_deleted_address
395
        )
396

397
    @property
1✔
398
    def emails_blocked(self) -> int:
1✔
399
        return (
1✔
400
            sum(ra.num_blocked for ra in self.relay_addresses)
401
            + sum(da.num_blocked for da in self.domain_addresses)
402
            + self.num_email_blocked_in_deleted_address
403
        )
404

405
    @property
1✔
406
    def emails_replied(self) -> int:
1✔
407
        ra_sum = self.relay_addresses.aggregate(models.Sum("num_replied", default=0))
1✔
408
        da_sum = self.domain_addresses.aggregate(models.Sum("num_replied", default=0))
1✔
409
        return (
1✔
410
            int(ra_sum["num_replied__sum"])
411
            + int(da_sum["num_replied__sum"])
412
            + self.num_email_replied_in_deleted_address
413
        )
414

415
    @property
1✔
416
    def level_one_trackers_blocked(self) -> int:
1✔
417
        return (
1✔
418
            sum(ra.num_level_one_trackers_blocked or 0 for ra in self.relay_addresses)
419
            + sum(
420
                da.num_level_one_trackers_blocked or 0 for da in self.domain_addresses
421
            )
422
            + (self.num_level_one_trackers_blocked_in_deleted_address or 0)
423
        )
424

425
    @property
1✔
426
    def joined_before_premium_release(self):
1✔
427
        date_created = self.user.date_joined
1✔
428
        return date_created < datetime.fromisoformat("2021-10-22 17:00:00+00:00")
1✔
429

430
    @property
1✔
431
    def date_phone_registered(self) -> datetime | None:
1✔
432
        if not settings.PHONES_ENABLED:
1!
433
            return None
×
434

435
        try:
1✔
436
            real_phone = RealPhone.objects.get(user=self.user)
1✔
437
            relay_number = RelayNumber.objects.get(user=self.user)
1✔
438
        except RealPhone.DoesNotExist:
1✔
439
            return None
1✔
440
        except RelayNumber.DoesNotExist:
1✔
441
            return real_phone.verified_date
1✔
442
        return relay_number.created_at or real_phone.verified_date
1✔
443

444
    def add_subdomain(self, subdomain):
1✔
445
        # Handles if the subdomain is "" or None
446
        if not subdomain:
1✔
447
            raise CannotMakeSubdomainException(
1✔
448
                "error-subdomain-cannot-be-empty-or-null"
449
            )
450

451
        # subdomain must be all lowercase
452
        subdomain = subdomain.lower()
1✔
453

454
        if not self.has_premium:
1✔
455
            raise CannotMakeSubdomainException("error-premium-set-subdomain")
1✔
456
        if self.subdomain is not None:
1✔
457
            raise CannotMakeSubdomainException("error-premium-cannot-change-subdomain")
1✔
458
        self.subdomain = subdomain
1✔
459
        # The validator defined in the subdomain field does not get run in full_clean()
460
        # when self.subdomain is "" or None, so we need to run the validator again to
461
        # catch these cases.
462
        valid_available_subdomain(subdomain)
1✔
463
        self.full_clean()
1✔
464
        self.save()
1✔
465

466
        RegisteredSubdomain.objects.create(subdomain_hash=hash_subdomain(subdomain))
1✔
467
        return subdomain
1✔
468

469
    def update_abuse_metric(
1✔
470
        self,
471
        address_created: bool = False,
472
        replied: bool = False,
473
        email_forwarded: bool = False,
474
        forwarded_email_size: int = 0,
475
    ) -> datetime | None:
476
        if self.user.email in settings.ALLOWED_ACCOUNTS:
1!
NEW
477
            return None
×
478

479
        with transaction.atomic():
1✔
480
            # look for abuse metrics created on the same UTC date, regardless of time.
481
            midnight_utc_today = datetime.combine(
1✔
482
                datetime.now(UTC).date(), datetime.min.time()
483
            ).astimezone(UTC)
484
            midnight_utc_tomorow = midnight_utc_today + timedelta(days=1)
1✔
485
            abuse_metric = (
1✔
486
                self.user.abusemetrics_set.select_for_update()
487
                .filter(
488
                    first_recorded__gte=midnight_utc_today,
489
                    first_recorded__lt=midnight_utc_tomorow,
490
                )
491
                .first()
492
            )
493
            if not abuse_metric:
1✔
494
                abuse_metric = AbuseMetrics.objects.create(user=self.user)
1✔
495
                AbuseMetrics.objects.filter(
1✔
496
                    first_recorded__lt=midnight_utc_today
497
                ).delete()
498

499
            # increment the abuse metric
500
            if address_created:
1✔
501
                abuse_metric.num_address_created_per_day += 1
1✔
502
            if replied:
1✔
503
                abuse_metric.num_replies_per_day += 1
1✔
504
            if email_forwarded:
1✔
505
                abuse_metric.num_email_forwarded_per_day += 1
1✔
506
            if forwarded_email_size > 0:
1✔
507
                abuse_metric.forwarded_email_size_per_day += forwarded_email_size
1✔
508
            abuse_metric.last_recorded = datetime.now(UTC)
1✔
509
            abuse_metric.save()
1✔
510

511
            # check user should be flagged for abuse
512
            hit_max_create = False
1✔
513
            hit_max_replies = False
1✔
514
            hit_max_forwarded = False
1✔
515
            hit_max_forwarded_email_size = False
1✔
516

517
            hit_max_create = (
1✔
518
                abuse_metric.num_address_created_per_day
519
                >= settings.MAX_ADDRESS_CREATION_PER_DAY
520
            )
521
            hit_max_replies = (
1✔
522
                abuse_metric.num_replies_per_day >= settings.MAX_REPLIES_PER_DAY
523
            )
524
            hit_max_forwarded = (
1✔
525
                abuse_metric.num_email_forwarded_per_day
526
                >= settings.MAX_FORWARDED_PER_DAY
527
            )
528
            hit_max_forwarded_email_size = (
1✔
529
                abuse_metric.forwarded_email_size_per_day
530
                >= settings.MAX_FORWARDED_EMAIL_SIZE_PER_DAY
531
            )
532
            if (
1✔
533
                hit_max_create
534
                or hit_max_replies
535
                or hit_max_forwarded
536
                or hit_max_forwarded_email_size
537
            ):
538
                self.last_account_flagged = datetime.now(UTC)
1✔
539
                self.save()
1✔
540
                data = {
1✔
541
                    "uid": self.fxa.uid if self.fxa else None,
542
                    "flagged": self.last_account_flagged.timestamp(),
543
                    "replies": abuse_metric.num_replies_per_day,
544
                    "addresses": abuse_metric.num_address_created_per_day,
545
                    "forwarded": abuse_metric.num_email_forwarded_per_day,
546
                    "forwarded_size_in_bytes": abuse_metric.forwarded_email_size_per_day,
547
                }
548
                # log for further secops review
549
                abuse_logger.info("Abuse flagged", extra=data)
1✔
550

551
        return self.last_account_flagged
1✔
552

553
    @property
1✔
554
    def is_flagged(self):
1✔
555
        if not self.last_account_flagged:
1✔
556
            return False
1✔
557
        account_premium_feature_resumed = self.last_account_flagged + timedelta(
1✔
558
            days=settings.PREMIUM_FEATURE_PAUSED_DAYS
559
        )
560
        if datetime.now(UTC) > account_premium_feature_resumed:
1!
561
            # premium feature has been resumed
562
            return False
×
563
        # user was flagged and the premium feature pause period is not yet over
564
        return True
1✔
565

566
    @property
1✔
567
    def metrics_enabled(self) -> bool:
1✔
568
        """
569
        Does the user allow us to record technical and interaction data?
570

571
        This is based on the Mozilla accounts opt-out option, added around 2022. A user
572
        can go to their Mozilla account profile settings, Data Collection and Use, and
573
        deselect "Help improve Mozilla Account". This setting defaults to On, and is
574
        sent as "metricsEnabled". Some older Relay accounts do not have
575
        "metricsEnabled", and we default to On.
576
        """
577
        if self.fxa:
1✔
578
            return bool(self.fxa.extra_data.get("metricsEnabled", True))
1✔
579
        return True
1✔
580

581
    @property
1✔
582
    def plan(self) -> Literal["free", "email", "phone", "bundle"]:
1✔
583
        """The user's Relay plan as a string."""
584
        if self.has_premium:
1✔
585
            if self.has_phone:
1✔
586
                return "bundle" if self.has_vpn else "phone"
1✔
587
            else:
588
                return "email"
1✔
589
        else:
590
            return "free"
1✔
591

592
    @property
1✔
593
    def plan_term(self) -> Literal[None, "unknown", "1_month", "1_year"]:
1✔
594
        """The user's Relay plan term as a string."""
595
        plan = self.plan
1✔
596
        if plan == "free":
1✔
597
            return None
1✔
598
        if plan == "phone":
1✔
599
            start_date = self.date_phone_subscription_start
1✔
600
            end_date = self.date_phone_subscription_end
1✔
601
            if start_date and end_date:
1✔
602
                span = end_date - start_date
1✔
603
                return "1_year" if span.days > 32 else "1_month"
1✔
604
        return "unknown"
1✔
605

606
    @property
1✔
607
    def metrics_premium_status(self) -> str:
1✔
608
        plan = self.plan
1✔
609
        if plan == "free":
1✔
610
            return "free"
1✔
611
        return f"{plan}_{self.plan_term}"
1✔
612

613

614
class RegisteredSubdomain(models.Model):
1✔
615
    subdomain_hash = models.CharField(max_length=64, db_index=True, unique=True)
1✔
616
    registered_at = models.DateTimeField(auto_now_add=True)
1✔
617

618
    def __str__(self):
1✔
619
        return self.subdomain_hash
×
620

621

622
class RelayAddress(models.Model):
1✔
623
    user = models.ForeignKey(User, on_delete=models.CASCADE)
1✔
624
    address = models.CharField(max_length=64, default=address_default, unique=True)
1✔
625
    domain = models.PositiveSmallIntegerField(
1✔
626
        choices=DOMAIN_CHOICES, default=default_domain_numerical
627
    )
628
    enabled = models.BooleanField(default=True)
1✔
629
    description = models.CharField(max_length=64, blank=True)
1✔
630
    created_at = models.DateTimeField(auto_now_add=True, db_index=True)
1✔
631
    last_modified_at = models.DateTimeField(auto_now=True, db_index=True)
1✔
632
    last_used_at = models.DateTimeField(blank=True, null=True)
1✔
633
    num_forwarded = models.PositiveIntegerField(default=0)
1✔
634
    num_blocked = models.PositiveIntegerField(default=0)
1✔
635
    num_level_one_trackers_blocked = models.PositiveIntegerField(default=0, null=True)
1✔
636
    num_replied = models.PositiveIntegerField(default=0)
1✔
637
    num_spam = models.PositiveIntegerField(default=0)
1✔
638
    generated_for = models.CharField(max_length=255, blank=True)
1✔
639
    block_list_emails = models.BooleanField(default=False)
1✔
640
    used_on = models.TextField(default=None, blank=True, null=True)
1✔
641

642
    class Meta:
1✔
643
        indexes = [
1✔
644
            # Find when a user first used the add-on
645
            models.Index(
646
                name="idx_ra_created_by_addon",
647
                fields=["user"],
648
                condition=~models.Q(generated_for__exact=""),
649
                include=["created_at"],
650
            ),
651
        ]
652
        verbose_name_plural = "relay addresses"
1✔
653

654
    def __str__(self):
1✔
655
        return self.address
1✔
656

657
    def delete(self, *args: Any, **kwargs: Any) -> tuple[int, dict[str, int]]:
1✔
658
        # TODO: create hard bounce receipt rule in AWS for the address
659
        deleted_address = DeletedAddress.objects.create(
1✔
660
            address_hash=address_hash(self.address, domain=self.domain_value),
661
            num_forwarded=self.num_forwarded,
662
            num_blocked=self.num_blocked,
663
            num_replied=self.num_replied,
664
            num_spam=self.num_spam,
665
        )
666
        deleted_address.save()
1✔
667
        profile = self.user.profile
1✔
668
        profile.address_last_deleted = datetime.now(UTC)
1✔
669
        profile.num_address_deleted += 1
1✔
670
        profile.num_email_forwarded_in_deleted_address += self.num_forwarded
1✔
671
        profile.num_email_blocked_in_deleted_address += self.num_blocked
1✔
672
        profile.num_level_one_trackers_blocked_in_deleted_address = (
1✔
673
            profile.num_level_one_trackers_blocked_in_deleted_address or 0
674
        ) + (self.num_level_one_trackers_blocked or 0)
675
        profile.num_email_replied_in_deleted_address += self.num_replied
1✔
676
        profile.num_email_spam_in_deleted_address += self.num_spam
1✔
677
        profile.num_deleted_relay_addresses += 1
1✔
678
        profile.last_engagement = datetime.now(UTC)
1✔
679
        profile.save()
1✔
680
        return super().delete(*args, **kwargs)
1✔
681

682
    def save(
1✔
683
        self,
684
        force_insert: bool | tuple[ModelBase, ...] = False,
685
        force_update: bool = False,
686
        using: str | None = None,
687
        update_fields: Iterable[str] | None = None,
688
    ) -> None:
689
        if self._state.adding:
1✔
690
            with transaction.atomic():
1✔
691
                locked_profile = Profile.objects.select_for_update().get(user=self.user)
1✔
692
                check_user_can_make_another_address(locked_profile.user)
1✔
693
                while True:
1✔
694
                    address_is_allowed = not is_blocklisted(self.address)
1✔
695
                    address_is_valid = valid_address(self.address, self.domain_value)
1✔
696
                    if address_is_valid and address_is_allowed:
1✔
697
                        break
1✔
698
                    self.address = address_default()
1✔
699
                locked_profile.update_abuse_metric(address_created=True)
1✔
700
                locked_profile.last_engagement = datetime.now(UTC)
1✔
701
                locked_profile.save()
1✔
702
        if (not self.user.profile.server_storage) and any(
1✔
703
            (self.description, self.generated_for, self.used_on)
704
        ):
705
            self.description = ""
1✔
706
            self.generated_for = ""
1✔
707
            self.used_on = ""
1✔
708
            if update_fields is not None:
1✔
709
                update_fields = {"description", "generated_for", "used_on"}.union(
1✔
710
                    update_fields
711
                )
712
        if not self.user.profile.has_premium and self.block_list_emails:
1✔
713
            self.block_list_emails = False
1✔
714
            if update_fields is not None:
1✔
715
                update_fields = {"block_list_emails"}.union(update_fields)
1✔
716
        super().save(
1✔
717
            force_insert=force_insert,
718
            force_update=force_update,
719
            using=using,
720
            update_fields=update_fields,
721
        )
722

723
    @property
1✔
724
    def domain_value(self) -> str:
1✔
725
        domain = cast(
1✔
726
            Literal["RELAY_FIREFOX_DOMAIN", "MOZMAIL_DOMAIN"], self.get_domain_display()
727
        )
728
        return get_domains_from_settings()[domain]
1✔
729

730
    @property
1✔
731
    def full_address(self) -> str:
1✔
732
        return f"{self.address}@{self.domain_value}"
1✔
733

734
    @property
1✔
735
    def metrics_id(self) -> str:
1✔
736
        if not self.id:
1!
737
            raise ValueError("self.id must be truthy value.")
×
738
        # Prefix with 'R' for RelayAddress, since there may be a DomainAddress with the
739
        # same row ID
740
        return f"R{self.id}"
1✔
741

742

743
class DeletedAddress(models.Model):
1✔
744
    address_hash = models.CharField(max_length=64, db_index=True)
1✔
745
    num_forwarded = models.PositiveIntegerField(default=0)
1✔
746
    num_blocked = models.PositiveIntegerField(default=0)
1✔
747
    num_replied = models.PositiveIntegerField(default=0)
1✔
748
    num_spam = models.PositiveIntegerField(default=0)
1✔
749

750
    def __str__(self):
1✔
751
        return self.address_hash
×
752

753

754
class DomainAddress(models.Model):
1✔
755
    user = models.ForeignKey(User, on_delete=models.CASCADE)
1✔
756
    address = models.CharField(
1✔
757
        max_length=64, validators=[MinLengthValidator(limit_value=1)]
758
    )
759
    enabled = models.BooleanField(default=True)
1✔
760
    description = models.CharField(max_length=64, blank=True)
1✔
761
    domain = models.PositiveSmallIntegerField(choices=DOMAIN_CHOICES, default=2)
1✔
762
    created_at = models.DateTimeField(auto_now_add=True, db_index=True)
1✔
763
    first_emailed_at = models.DateTimeField(null=True, db_index=True)
1✔
764
    last_modified_at = models.DateTimeField(auto_now=True, db_index=True)
1✔
765
    last_used_at = models.DateTimeField(blank=True, null=True)
1✔
766
    num_forwarded = models.PositiveIntegerField(default=0)
1✔
767
    num_blocked = models.PositiveIntegerField(default=0)
1✔
768
    num_level_one_trackers_blocked = models.PositiveIntegerField(default=0, null=True)
1✔
769
    num_replied = models.PositiveIntegerField(default=0)
1✔
770
    num_spam = models.PositiveIntegerField(default=0)
1✔
771
    block_list_emails = models.BooleanField(default=False)
1✔
772
    used_on = models.TextField(default=None, blank=True, null=True)
1✔
773

774
    class Meta:
1✔
775
        unique_together = ["user", "address"]
1✔
776
        verbose_name_plural = "domain addresses"
1✔
777

778
    def __str__(self):
1✔
779
        return self.address
×
780

781
    def save(
1✔
782
        self,
783
        force_insert: bool | tuple[ModelBase, ...] = False,
784
        force_update: bool = False,
785
        using: str | None = None,
786
        update_fields: Iterable[str] | None = None,
787
    ) -> None:
788
        if self._state.adding:
1✔
789
            check_user_can_make_domain_address(self.user)
1✔
790
            domain_address_valid = valid_address(
1✔
791
                self.address, self.domain_value, self.user.profile.subdomain
792
            )
793
            if not domain_address_valid:
1✔
794
                if self.first_emailed_at:
1!
795
                    incr_if_enabled("domainaddress.create_via_email_fail")
×
796
                raise DomainAddrUnavailableException(unavailable_address=self.address)
1✔
797

798
            if DomainAddress.objects.filter(
1✔
799
                user=self.user, address=self.address
800
            ).exists():
801
                raise DomainAddrDuplicateException(duplicate_address=self.address)
1✔
802

803
            self.user.profile.update_abuse_metric(address_created=True)
1✔
804
            self.user.profile.last_engagement = datetime.now(UTC)
1✔
805
            self.user.profile.save(update_fields=["last_engagement"])
1✔
806
            incr_if_enabled("domainaddress.create")
1✔
807
            if self.first_emailed_at:
1✔
808
                incr_if_enabled("domainaddress.create_via_email")
1✔
809
        else:
810
            # The model is in an update state, do not allow 'address' field updates
811
            existing_instance = DomainAddress.objects.get(id=self.id)
1✔
812
            if existing_instance.address != self.address:
1✔
813
                raise DomainAddrUpdateException()
1✔
814

815
        if not self.user.profile.has_premium and self.block_list_emails:
1✔
816
            self.block_list_emails = False
1✔
817
            if update_fields:
1✔
818
                update_fields = {"block_list_emails"}.union(update_fields)
1✔
819
        if (not self.user.profile.server_storage) and (
1✔
820
            self.description or self.used_on
821
        ):
822
            self.description = ""
1✔
823
            self.used_on = ""
1✔
824
            if update_fields:
1✔
825
                update_fields = {"description", "used_on"}.union(update_fields)
1✔
826
        super().save(
1✔
827
            force_insert=force_insert,
828
            force_update=force_update,
829
            using=using,
830
            update_fields=update_fields,
831
        )
832

833
    @staticmethod
1✔
834
    def make_domain_address(
1✔
835
        user: User, address: str | None = None, made_via_email: bool = False
836
    ) -> DomainAddress:
837
        check_user_can_make_domain_address(user)
1✔
838

839
        if not address:
1✔
840
            # FIXME: if the alias is randomly generated and has bad words
841
            # we should retry like make_relay_address does
842
            # not fixing this now because not sure randomly generated
843
            # DomainAlias will be a feature
844
            address = address_default()
1✔
845
            # Only check for bad words if randomly generated
846
        if not isinstance(address, str):
1!
847
            raise TypeError("address must be type str")
×
848

849
        first_emailed_at = datetime.now(UTC) if made_via_email else None
1✔
850
        domain_address = DomainAddress.objects.create(
1✔
851
            user=user, address=address, first_emailed_at=first_emailed_at
852
        )
853
        return domain_address
1✔
854

855
    def delete(self, *args, **kwargs):
1✔
856
        # TODO: create hard bounce receipt rule in AWS for the address
857
        deleted_address = DeletedAddress.objects.create(
1✔
858
            address_hash=address_hash(
859
                self.address, self.user.profile.subdomain, self.domain_value
860
            ),
861
            num_forwarded=self.num_forwarded,
862
            num_blocked=self.num_blocked,
863
            num_replied=self.num_replied,
864
            num_spam=self.num_spam,
865
        )
866
        deleted_address.save()
1✔
867
        profile = self.user.profile
1✔
868
        profile.address_last_deleted = datetime.now(UTC)
1✔
869
        profile.num_address_deleted += 1
1✔
870
        profile.num_email_forwarded_in_deleted_address += self.num_forwarded
1✔
871
        profile.num_email_blocked_in_deleted_address += self.num_blocked
1✔
872
        profile.num_level_one_trackers_blocked_in_deleted_address = (
1✔
873
            profile.num_level_one_trackers_blocked_in_deleted_address or 0
874
        ) + (self.num_level_one_trackers_blocked or 0)
875
        profile.num_email_replied_in_deleted_address += self.num_replied
1✔
876
        profile.num_email_spam_in_deleted_address += self.num_spam
1✔
877
        profile.num_deleted_domain_addresses += 1
1✔
878
        profile.last_engagement = datetime.now(UTC)
1✔
879
        profile.save()
1✔
880
        return super().delete(*args, **kwargs)
1✔
881

882
    @property
1✔
883
    def domain_value(self) -> str:
1✔
884
        domain = cast(
1✔
885
            Literal["RELAY_FIREFOX_DOMAIN", "MOZMAIL_DOMAIN"], self.get_domain_display()
886
        )
887
        return get_domains_from_settings()[domain]
1✔
888

889
    @property
1✔
890
    def full_address(self) -> str:
1✔
891
        return f"{self.address}@{self.user.profile.subdomain}.{self.domain_value}"
1✔
892

893
    @property
1✔
894
    def metrics_id(self) -> str:
1✔
895
        if not self.id:
1!
896
            raise ValueError("self.id must be truthy value.")
×
897
        # Prefix with 'D' for DomainAddress, since there may be a RelayAddress with the
898
        # same row ID
899
        return f"D{self.id}"
1✔
900

901

902
class Reply(models.Model):
1✔
903
    relay_address = models.ForeignKey(
1✔
904
        RelayAddress, on_delete=models.CASCADE, blank=True, null=True
905
    )
906
    domain_address = models.ForeignKey(
1✔
907
        DomainAddress, on_delete=models.CASCADE, blank=True, null=True
908
    )
909
    lookup = models.CharField(max_length=255, blank=False, db_index=True)
1✔
910
    encrypted_metadata = models.TextField(blank=False)
1✔
911
    created_at = models.DateField(auto_now_add=True, null=False, db_index=True)
1✔
912

913
    @property
1✔
914
    def address(self):
1✔
915
        return self.relay_address or self.domain_address
1✔
916

917
    @property
1✔
918
    def profile(self):
1✔
919
        return self.address.user.profile
1✔
920

921
    @property
1✔
922
    def owner_has_premium(self):
1✔
923
        return self.profile.has_premium
1✔
924

925
    def increment_num_replied(self):
1✔
926
        address = self.relay_address or self.domain_address
1✔
927
        if not address:
1!
928
            raise ValueError("address must be truthy value")
×
929
        address.num_replied += 1
1✔
930
        address.last_used_at = datetime.now(UTC)
1✔
931
        address.save(update_fields=["num_replied", "last_used_at"])
1✔
932
        return address.num_replied
1✔
933

934

935
class AbuseMetrics(models.Model):
1✔
936
    user = models.ForeignKey(User, on_delete=models.CASCADE)
1✔
937
    first_recorded = models.DateTimeField(auto_now_add=True, db_index=True)
1✔
938
    last_recorded = models.DateTimeField(auto_now_add=True, db_index=True)
1✔
939
    num_address_created_per_day = models.PositiveSmallIntegerField(default=0)
1✔
940
    num_replies_per_day = models.PositiveSmallIntegerField(default=0)
1✔
941
    # Values from 0 to 32767 are safe in all databases supported by Django.
942
    num_email_forwarded_per_day = models.PositiveSmallIntegerField(default=0)
1✔
943
    # Values from 0 to 9.2 exabytes are safe in all databases supported by Django.
944
    forwarded_email_size_per_day = models.PositiveBigIntegerField(default=0)
1✔
945

946
    class Meta:
1✔
947
        unique_together = ["user", "first_recorded"]
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc