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

mozilla / fx-private-relay / c7fef48c-1fe8-406b-a4b9-5bf8aefbe9c6

pending completion
c7fef48c-1fe8-406b-a4b9-5bf8aefbe9c6

Pull #3517

circleci

groovecoder
for MPP-3021: add sentry profiling
Pull Request #3517: for MPP-3021: add sentry profiling

1734 of 2616 branches covered (66.28%)

Branch coverage included in aggregate %.

44 of 44 new or added lines in 2 files covered. (100.0%)

5608 of 7492 relevant lines covered (74.85%)

18.6 hits per line

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

92.31
/emails/models.py
1
from collections import namedtuple
1✔
2
from datetime import datetime, timedelta, timezone
1✔
3
from hashlib import sha256
1✔
4
from typing import Optional
1✔
5
import logging
1✔
6
import random
1✔
7
import re
1✔
8
import string
1✔
9
import uuid
1✔
10

11
import sentry_sdk
1✔
12

13
from django.apps import apps
1✔
14
from django.conf import settings
1✔
15
from django.contrib.auth.models import User
1✔
16
from django.core.exceptions import BadRequest
1✔
17
from django.core.validators import MinLengthValidator
1✔
18
from django.db import models, transaction
1✔
19
from django.dispatch import receiver
1✔
20
from django.utils.translation.trans_real import (
1✔
21
    parse_accept_lang_header,
22
    get_supported_language_variant,
23
)
24

25
from rest_framework.authtoken.models import Token
1✔
26
from waffle.models import Flag
1✔
27

28
from api.exceptions import ErrorContextType, RelayAPIException
1✔
29

30

31
emails_config = apps.get_app_config("emails")
1✔
32
logger = logging.getLogger("events")
1✔
33
abuse_logger = logging.getLogger("abusemetrics")
1✔
34

35
BounceStatus = namedtuple("BounceStatus", "paused type")
1✔
36

37

38
def get_domains_from_settings():
1✔
39
    # HACK: detect if code is running in django tests
40
    if "testserver" in settings.ALLOWED_HOSTS:
1!
41
        return {"RELAY_FIREFOX_DOMAIN": "default.com", "MOZMAIL_DOMAIN": "test.com"}
1✔
42
    return {
×
43
        "RELAY_FIREFOX_DOMAIN": settings.RELAY_FIREFOX_DOMAIN,
44
        "MOZMAIL_DOMAIN": settings.MOZMAIL_DOMAIN,
45
    }
46

47

48
DOMAIN_CHOICES = [(1, "RELAY_FIREFOX_DOMAIN"), (2, "MOZMAIL_DOMAIN")]
1✔
49
PREMIUM_DOMAINS = ["mozilla.com", "getpocket.com", "mozillafoundation.org"]
1✔
50

51

52
def valid_available_subdomain(subdomain, *args, **kwargs):
1✔
53
    # valid subdomains:
54
    #   can't start or end with a hyphen
55
    #   must be 1-63 alphanumeric characters and/or hyphens
56
    subdomain = subdomain.lower()
1✔
57
    valid_subdomain_pattern = re.compile("^(?!-)[a-z0-9-]{1,63}(?<!-)$")
1✔
58
    valid = valid_subdomain_pattern.match(subdomain) is not None
1✔
59
    #   can't have "bad" words in them
60
    bad_word = has_bad_words(subdomain)
1✔
61
    #   can't have "blocked" words in them
62
    blocked_word = is_blocklisted(subdomain)
1✔
63
    #   can't be taken by someone else
64
    taken = (
1✔
65
        RegisteredSubdomain.objects.filter(
66
            subdomain_hash=hash_subdomain(subdomain)
67
        ).count()
68
        > 0
69
    )
70
    if not valid or bad_word or blocked_word or taken:
1✔
71
        raise CannotMakeSubdomainException("error-subdomain-not-available")
1✔
72
    return True
1✔
73

74

75
# This historical function is referenced in migration 0029_profile_add_deleted_metric_and_changeserver_storage_default
76
def default_server_storage():
1✔
77
    return True
×
78

79

80
def default_domain_numerical():
1✔
81
    domains = get_domains_from_settings()
1✔
82
    domain = domains["MOZMAIL_DOMAIN"]
1✔
83
    return get_domain_numerical(domain)
1✔
84

85

86
class Profile(models.Model):
1✔
87
    user = models.OneToOneField(User, on_delete=models.CASCADE)
1✔
88
    api_token = models.UUIDField(default=uuid.uuid4)
1✔
89
    num_address_deleted = models.PositiveIntegerField(default=0)
1✔
90
    date_subscribed = models.DateTimeField(blank=True, null=True)
1✔
91
    date_subscribed_phone = models.DateTimeField(blank=True, null=True)
1✔
92
    # TODO: delete date_phone_subscription_checked in favor of date_phone_subscription_next_reset
93
    date_phone_subscription_checked = models.DateTimeField(blank=True, null=True)
1✔
94
    date_phone_subscription_start = models.DateTimeField(blank=True, null=True)
1✔
95
    date_phone_subscription_reset = models.DateTimeField(blank=True, null=True)
1✔
96
    date_phone_subscription_end = models.DateTimeField(blank=True, null=True)
1✔
97
    address_last_deleted = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
98
    last_soft_bounce = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
99
    last_hard_bounce = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
100
    last_account_flagged = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
101
    num_email_forwarded_in_deleted_address = models.PositiveIntegerField(default=0)
1✔
102
    num_email_blocked_in_deleted_address = models.PositiveIntegerField(default=0)
1✔
103
    num_level_one_trackers_blocked_in_deleted_address = models.PositiveIntegerField(
1✔
104
        default=0, null=True
105
    )
106
    num_email_replied_in_deleted_address = models.PositiveIntegerField(default=0)
1✔
107
    num_email_spam_in_deleted_address = models.PositiveIntegerField(default=0)
1✔
108
    subdomain = models.CharField(
1✔
109
        blank=True,
110
        null=True,
111
        unique=True,
112
        max_length=63,
113
        db_index=True,
114
        validators=[valid_available_subdomain],
115
    )
116
    # Whether we store the user's alias labels in the server
117
    server_storage = models.BooleanField(default=True)
1✔
118
    # Whether we store the caller/sender log for the user's relay number
119
    store_phone_log = models.BooleanField(default=True)
1✔
120
    # TODO: Data migration to set null to false
121
    # TODO: Schema migration to remove null=True
122
    remove_level_one_email_trackers = models.BooleanField(null=True, default=False)
1✔
123
    onboarding_state = models.PositiveIntegerField(default=0)
1✔
124
    auto_block_spam = models.BooleanField(default=False)
1✔
125
    forwarded_first_reply = models.BooleanField(default=False)
1✔
126

127
    def __str__(self):
1✔
128
        return "%s Profile" % self.user
×
129

130
    def save(self, *args, **kwargs):
1✔
131
        with sentry_sdk.start_transaction(op="Profile.save", name="Profile.save"):
1✔
132
            # always lower-case the subdomain before saving it
133
            # TODO: change subdomain field as a custom field inheriting from
134
            # CharField to validate constraints on the field update too
135
            if self.subdomain and not self.subdomain.islower():
1✔
136
                self.subdomain = self.subdomain.lower()
1✔
137
            ret = super().save(*args, **kwargs)
1✔
138
            # any time a profile is saved with server_storage False, delete the
139
            # appropriate server-stored Relay address data.
140
            if not self.server_storage:
1✔
141
                relay_addresses = RelayAddress.objects.filter(user=self.user)
1✔
142
                relay_addresses.update(description="", generated_for="", used_on="")
1✔
143
            if settings.PHONES_ENABLED:
1!
144
                # any time a profile is saved with store_phone_log False, delete the
145
                # appropriate server-stored InboundContact records
146
                from phones.models import InboundContact, RelayNumber
1✔
147

148
                if not self.store_phone_log:
1✔
149
                    try:
1✔
150
                        relay_number = RelayNumber.objects.get(user=self.user)
1✔
151
                        InboundContact.objects.filter(
1✔
152
                            relay_number=relay_number
153
                        ).delete()
154
                    except RelayNumber.DoesNotExist:
1✔
155
                        pass
1✔
156
            return ret
1✔
157

158
    @property
1✔
159
    def language(self):
1✔
160
        if self.fxa and self.fxa.extra_data.get("locale"):
1✔
161
            for accept_lang, _ in parse_accept_lang_header(
1✔
162
                self.fxa.extra_data.get("locale")
163
            ):
164
                try:
1✔
165
                    return get_supported_language_variant(accept_lang)
1✔
166
                except LookupError:
1✔
167
                    continue
1✔
168
        return "en"
1✔
169

170
    # This method returns whether the locale associated with the user's Firefox account
171
    # includes a country code from a Premium country. This is less accurate than using
172
    # get_countries_info_from_request_and_mapping(), which uses a GeoIP lookup, so prefer
173
    # using that if a request context is available. In other contexts, e.g. when
174
    # sending an email, this method can be useful.
175
    @property
1✔
176
    def fxa_locale_in_premium_country(self):
1✔
177
        if self.fxa and self.fxa.extra_data.get("locale"):
1✔
178
            accept_langs = parse_accept_lang_header(self.fxa.extra_data.get("locale"))
1✔
179
            if (
1✔
180
                len(accept_langs) >= 1
181
                and len(accept_langs[0][0].split("-")) >= 2
182
                and accept_langs[0][0].split("-")[1]
183
                in settings.PERIODICAL_PREMIUM_PLAN_COUNTRY_LANG_MAPPING.keys()
184
            ):
185
                return True
1✔
186
            # If a language but no country is known, check if there's a country
187
            # whose code is the same as the language code and has Premium available.
188
            # (For example, if the locale is just the language code `fr` rather than
189
            # `fr-fr`, it will still match country code `fr`.)
190
            if (
1✔
191
                len(accept_langs) >= 1
192
                and len(accept_langs[0][0].split("-")) == 1
193
                and accept_langs[0][0].split("-")[0]
194
                in settings.PERIODICAL_PREMIUM_PLAN_COUNTRY_LANG_MAPPING.keys()
195
            ):
196
                return True
1✔
197
        return False
1✔
198

199
    @property
1✔
200
    def avatar(self):
1✔
201
        return self.fxa.extra_data.get("avatar")
×
202

203
    @property
1✔
204
    def relay_addresses(self):
1✔
205
        return RelayAddress.objects.filter(user=self.user)
1✔
206

207
    @property
1✔
208
    def domain_addresses(self):
1✔
209
        return DomainAddress.objects.filter(user=self.user)
1✔
210

211
    @property
1✔
212
    def total_masks(self) -> int:
1✔
213
        ra_count: int = self.relay_addresses.count()
1✔
214
        da_count: int = self.domain_addresses.count()
1✔
215
        return ra_count + da_count
1✔
216

217
    @property
1✔
218
    def at_mask_limit(self) -> bool:
1✔
219
        if self.has_premium:
1✔
220
            return False
1✔
221
        ra_count: int = self.relay_addresses.count()
1✔
222
        return ra_count >= settings.MAX_NUM_FREE_ALIASES
1✔
223

224
    def check_bounce_pause(self):
1✔
225
        with sentry_sdk.start_transaction(
1✔
226
            op="Profile.check_bounce_pause", name="Profile.check_bounce_pause"
227
        ):
228
            if self.last_hard_bounce:
1✔
229
                last_hard_bounce_allowed = datetime.now(timezone.utc) - timedelta(
1✔
230
                    days=settings.HARD_BOUNCE_ALLOWED_DAYS
231
                )
232
                if self.last_hard_bounce > last_hard_bounce_allowed:
1✔
233
                    return BounceStatus(True, "hard")
1✔
234
                self.last_hard_bounce = None
1✔
235
                self.save()
1✔
236
            if self.last_soft_bounce:
1✔
237
                last_soft_bounce_allowed = datetime.now(timezone.utc) - timedelta(
1✔
238
                    days=settings.SOFT_BOUNCE_ALLOWED_DAYS
239
                )
240
                if self.last_soft_bounce > last_soft_bounce_allowed:
1✔
241
                    return BounceStatus(True, "soft")
1✔
242
                self.last_soft_bounce = None
1✔
243
                self.save()
1✔
244
            return BounceStatus(False, "")
1✔
245

246
    @property
1✔
247
    def bounce_status(self):
1✔
248
        return self.check_bounce_pause()
×
249

250
    @property
1✔
251
    def next_email_try(self):
1✔
252
        bounce_pause, bounce_type = self.check_bounce_pause()
1✔
253

254
        if not bounce_pause:
1✔
255
            return datetime.now(timezone.utc)
1✔
256

257
        if bounce_type == "soft":
1✔
258
            return self.last_soft_bounce + timedelta(
1✔
259
                days=settings.SOFT_BOUNCE_ALLOWED_DAYS
260
            )
261

262
        return self.last_hard_bounce + timedelta(days=settings.HARD_BOUNCE_ALLOWED_DAYS)
1✔
263

264
    @property
1✔
265
    def last_bounce_date(self):
1✔
266
        if self.last_hard_bounce:
1✔
267
            return self.last_hard_bounce
1✔
268
        if self.last_soft_bounce:
1✔
269
            return self.last_soft_bounce
1✔
270
        return None
1✔
271

272
    @property
1✔
273
    def at_max_free_aliases(self) -> bool:
1✔
274
        relay_addresses_count: int = self.relay_addresses.count()
1✔
275
        return relay_addresses_count >= settings.MAX_NUM_FREE_ALIASES
1✔
276

277
    @property
1✔
278
    def fxa(self):
1✔
279
        # Note: we are NOT using .filter() here because it invalidates
280
        # any profile instances that were queried with prefetch_related, which
281
        # we use in at least the profile view to minimize queries
282
        for sa in self.user.socialaccount_set.all():
1✔
283
            if sa.provider == "fxa":
1!
284
                return sa
1✔
285
        return None
1✔
286

287
    @property
1✔
288
    def display_name(self):
1✔
289
        # if display name is not set on FxA the
290
        # displayName key will not exist on the extra_data
291
        return self.fxa.extra_data.get("displayName")
1✔
292

293
    @property
1✔
294
    def custom_domain(self):
1✔
295
        return f"@{self.subdomain}.{settings.MOZMAIL_DOMAIN}"
×
296

297
    @property
1✔
298
    def has_premium(self):
1✔
299
        with sentry_sdk.start_transaction(
1✔
300
            op="Profile.has_premium", name="Profile.has_premium"
301
        ):
302
            # FIXME: as we don't have all the tiers defined we are over-defining
303
            # this to mark the user as a premium user as well
304
            if not self.fxa:
1✔
305
                return False
1✔
306
            for premium_domain in PREMIUM_DOMAINS:
1✔
307
                if self.user.email.endswith(f"@{premium_domain}"):
1!
308
                    return True
×
309
            user_subscriptions = self.fxa.extra_data.get("subscriptions", [])
1✔
310
            for sub in settings.SUBSCRIPTIONS_WITH_UNLIMITED:
1✔
311
                if sub in user_subscriptions:
1✔
312
                    return True
1✔
313
            return False
1✔
314

315
    @property
1✔
316
    def has_phone(self):
1✔
317
        if not self.fxa:
1✔
318
            return False
1✔
319
        if settings.RELAY_CHANNEL != "prod" and not settings.IN_PYTEST:
1!
320
            try:
×
321
                phone_flag = Flag.objects.get(name="phones")
×
322
            except Flag.DoesNotExist:
×
323
                return False
×
324
            if not phone_flag.is_active_for_user(self.user):
×
325
                return False
×
326
        flags = Flag.objects.filter(name="free_phones")
1✔
327
        for flag in flags:
1!
328
            if flag.is_active_for_user(self.user):
×
329
                return True
×
330
        user_subscriptions = self.fxa.extra_data.get("subscriptions", [])
1✔
331
        for sub in settings.SUBSCRIPTIONS_WITH_PHONE:
1✔
332
            if sub in user_subscriptions:
1✔
333
                return True
1✔
334
        return False
1✔
335

336
    @property
1✔
337
    def has_vpn(self):
1✔
338
        if not self.fxa:
×
339
            return False
×
340
        user_subscriptions = self.fxa.extra_data.get("subscriptions", [])
×
341
        for sub in settings.SUBSCRIPTIONS_WITH_VPN:
×
342
            if sub in user_subscriptions:
×
343
                return True
×
344
        return False
×
345

346
    @property
1✔
347
    def emails_forwarded(self):
1✔
348
        return (
×
349
            sum(ra.num_forwarded for ra in self.relay_addresses)
350
            + sum(da.num_forwarded for da in self.domain_addresses)
351
            + self.num_email_forwarded_in_deleted_address
352
        )
353

354
    @property
1✔
355
    def emails_blocked(self):
1✔
356
        return (
×
357
            sum(ra.num_blocked for ra in self.relay_addresses)
358
            + sum(da.num_blocked for da in self.domain_addresses)
359
            + self.num_email_blocked_in_deleted_address
360
        )
361

362
    @property
1✔
363
    def emails_replied(self):
1✔
364
        # Once Django is on version 4.0 and above, we can set the default=0
365
        # and return a int instead of None
366
        # https://docs.djangoproject.com/en/4.0/ref/models/querysets/#default
367
        totals = [self.relay_addresses.aggregate(models.Sum("num_replied"))]
1✔
368
        totals.append(self.domain_addresses.aggregate(models.Sum("num_replied")))
1✔
369
        total_num_replied = 0
1✔
370
        for num in totals:
1✔
371
            total_num_replied += (
1✔
372
                num.get("num_replied__sum") if num.get("num_replied__sum") else 0
373
            )
374
        return total_num_replied + self.num_email_replied_in_deleted_address
1✔
375

376
    @property
1✔
377
    def level_one_trackers_blocked(self):
1✔
378
        return (
×
379
            sum(ra.num_level_one_trackers_blocked or 0 for ra in self.relay_addresses)
380
            + sum(
381
                da.num_level_one_trackers_blocked or 0 for da in self.domain_addresses
382
            )
383
            + (self.num_level_one_trackers_blocked_in_deleted_address or 0)
384
        )
385

386
    @property
1✔
387
    def joined_before_premium_release(self):
1✔
388
        date_created = self.user.date_joined
1✔
389
        return date_created < datetime.fromisoformat("2021-10-22 17:00:00+00:00")
1✔
390

391
    def add_subdomain(self, subdomain):
1✔
392
        # subdomain must be all lowercase
393
        subdomain = subdomain.lower()
1✔
394
        if not self.has_premium:
1✔
395
            raise CannotMakeSubdomainException("error-premium-set-subdomain")
1✔
396
        if self.subdomain is not None:
1✔
397
            raise CannotMakeSubdomainException("error-premium-cannot-change-subdomain")
1✔
398
        self.subdomain = subdomain
1✔
399
        self.full_clean()
1✔
400
        self.save()
1✔
401

402
        RegisteredSubdomain.objects.create(subdomain_hash=hash_subdomain(subdomain))
1✔
403
        return subdomain
1✔
404

405
    def update_abuse_metric(
1✔
406
        self,
407
        address_created=False,
408
        replied=False,
409
        email_forwarded=False,
410
        forwarded_email_size=0,
411
    ):
412
        #  TODO: this should be wrapped in atomic to ensure race conditions are properly handled
413
        # look for abuse metrics created on the same UTC date, regardless of time.
414
        midnight_utc_today = datetime.combine(
1✔
415
            datetime.now(timezone.utc).date(), datetime.min.time()
416
        ).astimezone(timezone.utc)
417
        midnight_utc_tomorow = midnight_utc_today + timedelta(days=1)
1✔
418
        abuse_metric = self.user.abusemetrics_set.filter(
1✔
419
            first_recorded__gte=midnight_utc_today,
420
            first_recorded__lt=midnight_utc_tomorow,
421
        ).first()
422
        if not abuse_metric:
1✔
423
            abuse_metric = AbuseMetrics.objects.create(user=self.user)
1✔
424
            AbuseMetrics.objects.filter(first_recorded__lt=midnight_utc_today).delete()
1✔
425

426
        # increment the abuse metric
427
        if address_created:
1✔
428
            abuse_metric.num_address_created_per_day += 1
1✔
429
        if replied:
1✔
430
            abuse_metric.num_replies_per_day += 1
1✔
431
        if email_forwarded:
1✔
432
            abuse_metric.num_email_forwarded_per_day += 1
1✔
433
        if forwarded_email_size > 0:
1✔
434
            abuse_metric.forwarded_email_size_per_day += forwarded_email_size
1✔
435
        abuse_metric.last_recorded = datetime.now(timezone.utc)
1✔
436
        abuse_metric.save()
1✔
437

438
        # check user should be flagged for abuse
439
        hit_max_create = False
1✔
440
        hit_max_replies = False
1✔
441
        hit_max_forwarded = False
1✔
442
        hit_max_forwarded_email_size = False
1✔
443

444
        hit_max_create = (
1✔
445
            abuse_metric.num_address_created_per_day
446
            >= settings.MAX_ADDRESS_CREATION_PER_DAY
447
        )
448
        hit_max_replies = (
1✔
449
            abuse_metric.num_replies_per_day >= settings.MAX_REPLIES_PER_DAY
450
        )
451
        hit_max_forwarded = (
1✔
452
            abuse_metric.num_email_forwarded_per_day >= settings.MAX_FORWARDED_PER_DAY
453
        )
454
        hit_max_forwarded_email_size = (
1✔
455
            abuse_metric.forwarded_email_size_per_day
456
            >= settings.MAX_FORWARDED_EMAIL_SIZE_PER_DAY
457
        )
458
        if (
1✔
459
            hit_max_create
460
            or hit_max_replies
461
            or hit_max_forwarded
462
            or hit_max_forwarded_email_size
463
        ):
464
            self.last_account_flagged = datetime.now(timezone.utc)
1✔
465
            self.save()
1✔
466
            data = {
1✔
467
                "uid": self.fxa.uid,
468
                "flagged": self.last_account_flagged.timestamp(),
469
                "replies": abuse_metric.num_replies_per_day,
470
                "addresses": abuse_metric.num_address_created_per_day,
471
                "forwarded": abuse_metric.num_email_forwarded_per_day,
472
                "forwarded_size_in_bytes": abuse_metric.forwarded_email_size_per_day,
473
            }
474
            # log for further secops review
475
            abuse_logger.info("Abuse flagged", extra=data)
1✔
476
        return self.last_account_flagged
1✔
477

478
    @property
1✔
479
    def is_flagged(self):
1✔
480
        if not self.last_account_flagged:
1✔
481
            return False
1✔
482
        account_premium_feature_resumed = self.last_account_flagged + timedelta(
1✔
483
            days=settings.PREMIUM_FEATURE_PAUSED_DAYS
484
        )
485
        if datetime.now(timezone.utc) > account_premium_feature_resumed:
1!
486
            # premium feature has been resumed
487
            return False
×
488
        # user was flagged and the premium feature pause period is not yet over
489
        return True
1✔
490

491

492
@receiver(models.signals.post_save, sender=Profile)
1✔
493
def copy_auth_token(sender, instance=None, created=False, **kwargs):
1✔
494
    if created:
1✔
495
        # baker triggers created during tests
496
        # so first check the user doesn't already have a Token
497
        try:
1✔
498
            Token.objects.get(user=instance.user)
1✔
499
            return
1✔
500
        except Token.DoesNotExist:
1✔
501
            Token.objects.create(user=instance.user, key=instance.api_token)
1✔
502

503

504
def address_hash(address, subdomain=None, domain=None):
1✔
505
    if not domain:
1✔
506
        domain = get_domains_from_settings()["MOZMAIL_DOMAIN"]
1✔
507
    if subdomain:
1✔
508
        return sha256(f"{address}@{subdomain}.{domain}".encode("utf-8")).hexdigest()
1✔
509
    if domain == settings.RELAY_FIREFOX_DOMAIN:
1✔
510
        return sha256(f"{address}".encode("utf-8")).hexdigest()
1✔
511
    return sha256(f"{address}@{domain}".encode("utf-8")).hexdigest()
1✔
512

513

514
def address_default():
1✔
515
    return "".join(random.choices(string.ascii_lowercase + string.digits, k=9))
1✔
516

517

518
def has_bad_words(value):
1✔
519
    for badword in emails_config.badwords:
1✔
520
        badword = badword.strip()
1✔
521
        if len(badword) <= 4 and badword == value:
1✔
522
            return True
1✔
523
        if len(badword) > 4 and badword in value:
1✔
524
            return True
1✔
525
    return False
1✔
526

527

528
def is_blocklisted(value):
1✔
529
    return any(blockedword == value for blockedword in emails_config.blocklist)
1✔
530

531

532
def get_domain_numerical(domain_address):
1✔
533
    # get domain name from the address
534
    domains = get_domains_from_settings()
1✔
535
    domains_keys = list(domains.keys())
1✔
536
    domains_values = list(domains.values())
1✔
537
    domain_name = domains_keys[domains_values.index(domain_address)]
1✔
538
    # get domain numerical value from domain name
539
    choices = dict(DOMAIN_CHOICES)
1✔
540
    choices_keys = list(choices.keys())
1✔
541
    choices_values = list(choices.values())
1✔
542
    return choices_keys[choices_values.index(domain_name)]
1✔
543

544

545
def hash_subdomain(subdomain, domain=settings.MOZMAIL_DOMAIN):
1✔
546
    return sha256(f"{subdomain}.{domain}".encode("utf-8")).hexdigest()
1✔
547

548

549
class RegisteredSubdomain(models.Model):
1✔
550
    subdomain_hash = models.CharField(max_length=64, db_index=True, unique=True)
1✔
551
    registered_at = models.DateTimeField(auto_now_add=True)
1✔
552

553
    def __str__(self):
1✔
554
        return self.subdomain_hash
×
555

556

557
class CannotMakeSubdomainException(BadRequest):
1✔
558
    """Exception raised by Profile due to error on subdomain creation.
559

560
    Attributes:
561
        message -- optional explanation of the error
562
    """
563

564
    def __init__(self, message=None):
1✔
565
        self.message = message
1✔
566

567

568
class CannotMakeAddressException(RelayAPIException):
1✔
569
    """Base exception for RelayAddress or DomainAddress creation failure."""
570

571

572
class AccountIsPausedException(CannotMakeAddressException):
1✔
573
    default_code = "account_is_paused"
1✔
574
    default_detail = "Your account is on pause."
1✔
575
    status_code = 403
1✔
576

577

578
class RelayAddrFreeTierLimitException(CannotMakeAddressException):
1✔
579
    default_code = "free_tier_limit"
1✔
580
    default_detail_template = (
1✔
581
        "You’ve used all {free_tier_limit} email masks included with your free account."
582
        "You can reuse an existing mask, but using a unique mask for each account is the most secure option."
583
    )
584
    status_code = 403
1✔
585

586
    def __init__(self, free_tier_limit: Optional[int] = None, *args, **kwargs):
1✔
587
        self.free_tier_limit = free_tier_limit or settings.MAX_NUM_FREE_ALIASES
1✔
588
        self.default_detail = self.default_detail_template.format(
1✔
589
            free_tier_limit=self.free_tier_limit
590
        )
591
        super().__init__(*args, **kwargs)
1✔
592

593
    def error_context(self) -> ErrorContextType:
1✔
594
        return {"free_tier_limit": self.free_tier_limit}
1✔
595

596

597
class DomainAddrFreeTierException(CannotMakeAddressException):
1✔
598
    default_code = "free_tier_no_subdomain_masks"
1✔
599
    default_detail = "Your free account does not include custom subdomains for masks. To create custom masks, upgrade to Relay Premium."
1✔
600
    status_code = 403
1✔
601

602

603
class DomainAddrNeedSubdomainException(CannotMakeAddressException):
1✔
604
    default_code = "need_subdomain"
1✔
605
    default_detail = "Please select a subdomain before creating a custom email address."
1✔
606
    status_code = 400
1✔
607

608

609
class DomainAddrUnavailableException(CannotMakeAddressException):
1✔
610
    default_code = "address_unavailable"
1✔
611
    default_detail_template = "“{unavailable_address}” could not be created. Please try again with a different mask name."
1✔
612
    status_code = 400
1✔
613

614
    def __init__(self, unavailable_address: str, *args, **kwargs):
1✔
615
        self.unavailable_address = unavailable_address
1✔
616
        self.default_detail = self.default_detail_template.format(
1✔
617
            unavailable_address=self.unavailable_address
618
        )
619
        super().__init__(*args, **kwargs)
1✔
620

621
    def error_context(self) -> ErrorContextType:
1✔
622
        return {"unavailable_address": self.unavailable_address}
1✔
623

624

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

645
    def __str__(self):
1✔
646
        return self.address
×
647

648
    def delete(self, *args, **kwargs):
1✔
649
        # TODO: create hard bounce receipt rule in AWS for the address
650
        deleted_address = DeletedAddress.objects.create(
1✔
651
            address_hash=address_hash(self.address, domain=self.domain_value),
652
            num_forwarded=self.num_forwarded,
653
            num_blocked=self.num_blocked,
654
            num_replied=self.num_replied,
655
            num_spam=self.num_spam,
656
        )
657
        deleted_address.save()
1✔
658
        profile = Profile.objects.get(user=self.user)
1✔
659
        profile.address_last_deleted = datetime.now(timezone.utc)
1✔
660
        profile.num_address_deleted += 1
1✔
661
        profile.num_email_forwarded_in_deleted_address += self.num_forwarded
1✔
662
        profile.num_email_blocked_in_deleted_address += self.num_blocked
1✔
663
        profile.num_level_one_trackers_blocked_in_deleted_address = (
1✔
664
            profile.num_level_one_trackers_blocked_in_deleted_address or 0
665
        ) + (self.num_level_one_trackers_blocked or 0)
666
        profile.num_email_replied_in_deleted_address += self.num_replied
1✔
667
        profile.num_email_spam_in_deleted_address += self.num_spam
1✔
668
        profile.save()
1✔
669
        return super(RelayAddress, self).delete(*args, **kwargs)
1✔
670

671
    def save(self, *args, **kwargs):
1✔
672
        if self._state.adding:
1✔
673
            with transaction.atomic():
1✔
674
                locked_profile = Profile.objects.select_for_update().get(user=self.user)
1✔
675
                check_user_can_make_another_address(locked_profile)
1✔
676
                while True:
1✔
677
                    if valid_address(self.address, self.domain):
1!
678
                        break
1✔
679
                    self.address = address_default()
×
680
                locked_profile.update_abuse_metric(address_created=True)
1✔
681
        if not self.user.profile.server_storage:
1✔
682
            self.description = ""
1✔
683
            self.generated_for = ""
1✔
684
            self.used_on = ""
1✔
685
        return super().save(*args, **kwargs)
1✔
686

687
    @property
1✔
688
    def domain_value(self):
1✔
689
        return get_domains_from_settings().get(self.get_domain_display())
1✔
690

691
    @property
1✔
692
    def full_address(self):
1✔
693
        return "%s@%s" % (self.address, self.domain_value)
1✔
694

695

696
def check_user_can_make_another_address(profile: Profile) -> None:
1✔
697
    if profile.is_flagged:
1✔
698
        raise AccountIsPausedException()
1✔
699
    if profile.at_max_free_aliases and not profile.has_premium:
1✔
700
        raise RelayAddrFreeTierLimitException()
1✔
701

702

703
def valid_address_pattern(address):
1✔
704
    #   can't start or end with a hyphen
705
    #   must be 1-63 lowercase alphanumeric characters and/or hyphens
706
    valid_address_pattern = re.compile("^(?![-.])[a-z0-9-.]{1,63}(?<![-.])$")
1✔
707
    return valid_address_pattern.match(address) is not None
1✔
708

709

710
def valid_address(address, domain):
1✔
711
    address_pattern_valid = valid_address_pattern(address)
1✔
712
    address_contains_badword = has_bad_words(address)
1✔
713
    address_is_blocklisted = is_blocklisted(address)
1✔
714
    address_already_deleted = DeletedAddress.objects.filter(
1✔
715
        address_hash=address_hash(address, domain=domain)
716
    ).count()
717
    if (
1✔
718
        address_already_deleted > 0
719
        or address_contains_badword
720
        or address_is_blocklisted
721
        or not address_pattern_valid
722
    ):
723
        return False
1✔
724
    return True
1✔
725

726

727
class DeletedAddress(models.Model):
1✔
728
    address_hash = models.CharField(max_length=64, db_index=True)
1✔
729
    num_forwarded = models.PositiveIntegerField(default=0)
1✔
730
    num_blocked = models.PositiveIntegerField(default=0)
1✔
731
    num_replied = models.PositiveIntegerField(default=0)
1✔
732
    num_spam = models.PositiveIntegerField(default=0)
1✔
733

734
    def __str__(self):
1✔
735
        return self.address_hash
×
736

737

738
def check_user_can_make_domain_address(user_profile: Profile) -> None:
1✔
739
    if not user_profile.has_premium:
1✔
740
        raise DomainAddrFreeTierException()
1✔
741

742
    if not user_profile.subdomain:
1✔
743
        raise DomainAddrNeedSubdomainException()
1✔
744

745
    if user_profile.is_flagged:
1✔
746
        raise AccountIsPausedException()
1✔
747

748

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

769
    class Meta:
1✔
770
        unique_together = ["user", "address"]
1✔
771

772
    def __str__(self):
1✔
773
        return self.address
×
774

775
    def save(self, *args, **kwargs) -> None:
1✔
776
        user_profile = self.user.profile
1✔
777
        if self._state.adding:
1✔
778
            check_user_can_make_domain_address(user_profile)
1✔
779
            pattern_valid = valid_address_pattern(self.address)
1✔
780
            address_contains_badword = has_bad_words(self.address)
1✔
781
            if not pattern_valid or address_contains_badword:
1✔
782
                raise DomainAddrUnavailableException(unavailable_address=self.address)
1✔
783
            user_profile.update_abuse_metric(address_created=True)
1✔
784
        # TODO: validate user is premium to set block_list_emails
785
        if not user_profile.server_storage:
1✔
786
            self.description = ""
1✔
787
        return super().save(*args, **kwargs)
1✔
788

789
    @property
1✔
790
    def user_profile(self):
1✔
791
        return Profile.objects.get(user=self.user)
1✔
792

793
    @staticmethod
1✔
794
    def make_domain_address(user_profile, address=None, made_via_email=False):
1✔
795
        check_user_can_make_domain_address(user_profile)
1✔
796

797
        address_contains_badword = False
1✔
798
        if not address:
1✔
799
            # FIXME: if the alias is randomly generated and has bad words
800
            # we should retry like make_relay_address does
801
            # not fixing this now because not sure randomly generated
802
            # DomainAlias will be a feature
803
            address = address_default()
1✔
804
            # Only check for bad words if randomly generated
805

806
        domain_address = DomainAddress.objects.create(
1✔
807
            user=user_profile.user,
808
            address=address,
809
        )
810
        if made_via_email:
1✔
811
            # update first_emailed_at indicating alias generation impromptu.
812
            domain_address.first_emailed_at = datetime.now(timezone.utc)
1✔
813
            domain_address.save()
1✔
814
        return domain_address
1✔
815

816
    def delete(self, *args, **kwargs):
1✔
817
        # TODO: create hard bounce receipt rule in AWS for the address
818
        deleted_address = DeletedAddress.objects.create(
1✔
819
            address_hash=address_hash(
820
                self.address, self.user_profile.subdomain, self.domain_value
821
            ),
822
            num_forwarded=self.num_forwarded,
823
            num_blocked=self.num_blocked,
824
            num_replied=self.num_replied,
825
            num_spam=self.num_spam,
826
        )
827
        deleted_address.save()
1✔
828
        # self.user_profile is a property and should not be used to
829
        # update values on the user's profile
830
        profile = Profile.objects.get(user=self.user)
1✔
831
        profile.address_last_deleted = datetime.now(timezone.utc)
1✔
832
        profile.num_address_deleted += 1
1✔
833
        profile.num_email_forwarded_in_deleted_address += self.num_forwarded
1✔
834
        profile.num_email_blocked_in_deleted_address += self.num_blocked
1✔
835
        profile.num_level_one_trackers_blocked_in_deleted_address = (
1✔
836
            profile.num_level_one_trackers_blocked_in_deleted_address or 0
837
        ) + (self.num_level_one_trackers_blocked or 0)
838
        profile.num_email_replied_in_deleted_address += self.num_replied
1✔
839
        profile.num_email_spam_in_deleted_address += self.num_spam
1✔
840
        profile.save()
1✔
841
        return super(DomainAddress, self).delete(*args, **kwargs)
1✔
842

843
    @property
1✔
844
    def domain_value(self):
1✔
845
        return get_domains_from_settings().get(self.get_domain_display())
1✔
846

847
    @property
1✔
848
    def full_address(self):
1✔
849
        return "%s@%s.%s" % (
1✔
850
            self.address,
851
            self.user_profile.subdomain,
852
            self.domain_value,
853
        )
854

855

856
class Reply(models.Model):
1✔
857
    relay_address = models.ForeignKey(
1✔
858
        RelayAddress, on_delete=models.CASCADE, blank=True, null=True
859
    )
860
    domain_address = models.ForeignKey(
1✔
861
        DomainAddress, on_delete=models.CASCADE, blank=True, null=True
862
    )
863
    lookup = models.CharField(max_length=255, blank=False, db_index=True)
1✔
864
    encrypted_metadata = models.TextField(blank=False)
1✔
865
    created_at = models.DateField(auto_now_add=True, null=False, db_index=True)
1✔
866

867
    @property
1✔
868
    def address(self):
1✔
869
        return self.relay_address or self.domain_address
1✔
870

871
    @property
1✔
872
    def profile(self):
1✔
873
        return self.address.user.profile
1✔
874

875
    @property
1✔
876
    def owner_has_premium(self):
1✔
877
        return self.profile.has_premium
1✔
878

879
    def increment_num_replied(self):
1✔
880
        address = self.relay_address or self.domain_address
1✔
881
        address.num_replied += 1
1✔
882
        address.last_used_at = datetime.now(timezone.utc)
1✔
883
        address.save(update_fields=["num_replied", "last_used_at"])
1✔
884
        return address.num_replied
1✔
885

886

887
class AbuseMetrics(models.Model):
1✔
888
    user = models.ForeignKey(User, on_delete=models.CASCADE)
1✔
889
    first_recorded = models.DateTimeField(auto_now_add=True, db_index=True)
1✔
890
    last_recorded = models.DateTimeField(auto_now_add=True, db_index=True)
1✔
891
    num_address_created_per_day = models.PositiveSmallIntegerField(default=0)
1✔
892
    num_replies_per_day = models.PositiveSmallIntegerField(default=0)
1✔
893
    # Values from 0 to 32767 are safe in all databases supported by Django.
894
    num_email_forwarded_per_day = models.PositiveSmallIntegerField(default=0)
1✔
895
    # Values from 0 to 9223372036854775807 are safe in all databases supported by Django.
896
    forwarded_email_size_per_day = models.PositiveBigIntegerField(default=0)
1✔
897

898
    class Meta:
1✔
899
        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