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

mozilla / fx-private-relay / 8354d07c-7eab-4972-926d-a2104e534166

pending completion
8354d07c-7eab-4972-926d-a2104e534166

Pull #3517

circleci

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

1720 of 2602 branches covered (66.1%)

Branch coverage included in aggregate %.

5602 of 7486 relevant lines covered (74.83%)

18.61 hits per line

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

92.18
/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
from django.apps import apps
1✔
11
from django.conf import settings
1✔
12
from django.contrib.auth.models import User
1✔
13
from django.core.exceptions import BadRequest
1✔
14
from django.core.validators import MinLengthValidator
1✔
15
from django.db import models, transaction
1✔
16
from django.dispatch import receiver
1✔
17
from django.utils.translation.trans_real import (
1✔
18
    parse_accept_lang_header,
19
    get_supported_language_variant,
20
)
21

22
from rest_framework.authtoken.models import Token
1✔
23
from waffle.models import Flag
1✔
24

25
from api.exceptions import ErrorContextType, RelayAPIException
1✔
26

27

28
emails_config = apps.get_app_config("emails")
1✔
29
logger = logging.getLogger("events")
1✔
30
abuse_logger = logging.getLogger("abusemetrics")
1✔
31

32
BounceStatus = namedtuple("BounceStatus", "paused type")
1✔
33

34

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

44

45
DOMAIN_CHOICES = [(1, "RELAY_FIREFOX_DOMAIN"), (2, "MOZMAIL_DOMAIN")]
1✔
46
PREMIUM_DOMAINS = ["mozilla.com", "getpocket.com", "mozillafoundation.org"]
1✔
47

48

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

71

72
# This historical function is referenced in migration 0029_profile_add_deleted_metric_and_changeserver_storage_default
73
def default_server_storage():
1✔
74
    return True
×
75

76

77
def default_domain_numerical():
1✔
78
    domains = get_domains_from_settings()
1✔
79
    domain = domains["MOZMAIL_DOMAIN"]
1✔
80
    return get_domain_numerical(domain)
1✔
81

82

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

124
    def __str__(self):
1✔
125
        return "%s Profile" % self.user
×
126

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

144
            if not self.store_phone_log:
1✔
145
                try:
1✔
146
                    relay_number = RelayNumber.objects.get(user=self.user)
1✔
147
                    InboundContact.objects.filter(relay_number=relay_number).delete()
1✔
148
                except RelayNumber.DoesNotExist:
1✔
149
                    pass
1✔
150
        return ret
1✔
151

152
    @property
1✔
153
    def language(self):
1✔
154
        if self.fxa and self.fxa.extra_data.get("locale"):
1✔
155
            for accept_lang, _ in parse_accept_lang_header(
1✔
156
                self.fxa.extra_data.get("locale")
157
            ):
158
                try:
1✔
159
                    return get_supported_language_variant(accept_lang)
1✔
160
                except LookupError:
1✔
161
                    continue
1✔
162
        return "en"
1✔
163

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

193
    @property
1✔
194
    def avatar(self):
1✔
195
        return self.fxa.extra_data.get("avatar")
×
196

197
    @property
1✔
198
    def relay_addresses(self):
1✔
199
        return RelayAddress.objects.filter(user=self.user)
1✔
200

201
    @property
1✔
202
    def domain_addresses(self):
1✔
203
        return DomainAddress.objects.filter(user=self.user)
1✔
204

205
    @property
1✔
206
    def total_masks(self) -> int:
1✔
207
        ra_count: int = self.relay_addresses.count()
1✔
208
        da_count: int = self.domain_addresses.count()
1✔
209
        return ra_count + da_count
1✔
210

211
    @property
1✔
212
    def at_mask_limit(self) -> bool:
1✔
213
        if self.has_premium:
1✔
214
            return False
1✔
215
        ra_count: int = self.relay_addresses.count()
1✔
216
        return ra_count >= settings.MAX_NUM_FREE_ALIASES
1✔
217

218
    def check_bounce_pause(self):
1✔
219
        if self.last_hard_bounce:
1✔
220
            last_hard_bounce_allowed = datetime.now(timezone.utc) - timedelta(
1✔
221
                days=settings.HARD_BOUNCE_ALLOWED_DAYS
222
            )
223
            if self.last_hard_bounce > last_hard_bounce_allowed:
1✔
224
                return BounceStatus(True, "hard")
1✔
225
            self.last_hard_bounce = None
1✔
226
            self.save()
1✔
227
        if self.last_soft_bounce:
1✔
228
            last_soft_bounce_allowed = datetime.now(timezone.utc) - timedelta(
1✔
229
                days=settings.SOFT_BOUNCE_ALLOWED_DAYS
230
            )
231
            if self.last_soft_bounce > last_soft_bounce_allowed:
1✔
232
                return BounceStatus(True, "soft")
1✔
233
            self.last_soft_bounce = None
1✔
234
            self.save()
1✔
235
        return BounceStatus(False, "")
1✔
236

237
    @property
1✔
238
    def bounce_status(self):
1✔
239
        return self.check_bounce_pause()
×
240

241
    @property
1✔
242
    def next_email_try(self):
1✔
243
        bounce_pause, bounce_type = self.check_bounce_pause()
1✔
244

245
        if not bounce_pause:
1✔
246
            return datetime.now(timezone.utc)
1✔
247

248
        if bounce_type == "soft":
1✔
249
            return self.last_soft_bounce + timedelta(
1✔
250
                days=settings.SOFT_BOUNCE_ALLOWED_DAYS
251
            )
252

253
        return self.last_hard_bounce + timedelta(days=settings.HARD_BOUNCE_ALLOWED_DAYS)
1✔
254

255
    @property
1✔
256
    def last_bounce_date(self):
1✔
257
        if self.last_hard_bounce:
1✔
258
            return self.last_hard_bounce
1✔
259
        if self.last_soft_bounce:
1✔
260
            return self.last_soft_bounce
1✔
261
        return None
1✔
262

263
    @property
1✔
264
    def at_max_free_aliases(self) -> bool:
1✔
265
        relay_addresses_count: int = self.relay_addresses.count()
1✔
266
        return relay_addresses_count >= settings.MAX_NUM_FREE_ALIASES
1✔
267

268
    @property
1✔
269
    def fxa(self):
1✔
270
        # Note: we are NOT using .filter() here because it invalidates
271
        # any profile instances that were queried with prefetch_related, which
272
        # we use in at least the profile view to minimize queries
273
        for sa in self.user.socialaccount_set.all():
1✔
274
            if sa.provider == "fxa":
1!
275
                return sa
1✔
276
        return None
1✔
277

278
    @property
1✔
279
    def display_name(self):
1✔
280
        # if display name is not set on FxA the
281
        # displayName key will not exist on the extra_data
282
        return self.fxa.extra_data.get("displayName")
1✔
283

284
    @property
1✔
285
    def custom_domain(self):
1✔
286
        return f"@{self.subdomain}.{settings.MOZMAIL_DOMAIN}"
×
287

288
    @property
1✔
289
    def has_premium(self):
1✔
290
        # FIXME: as we don't have all the tiers defined we are over-defining
291
        # this to mark the user as a premium user as well
292
        if not self.fxa:
1✔
293
            return False
1✔
294
        for premium_domain in PREMIUM_DOMAINS:
1✔
295
            if self.user.email.endswith(f"@{premium_domain}"):
1!
296
                return True
×
297
        user_subscriptions = self.fxa.extra_data.get("subscriptions", [])
1✔
298
        for sub in settings.SUBSCRIPTIONS_WITH_UNLIMITED:
1✔
299
            if sub in user_subscriptions:
1✔
300
                return True
1✔
301
        return False
1✔
302

303
    @property
1✔
304
    def has_phone(self):
1✔
305
        if not self.fxa:
1✔
306
            return False
1✔
307
        if settings.RELAY_CHANNEL != "prod" and not settings.IN_PYTEST:
1!
308
            try:
×
309
                phone_flag = Flag.objects.get(name="phones")
×
310
            except Flag.DoesNotExist:
×
311
                return False
×
312
            if not phone_flag.is_active_for_user(self.user):
×
313
                return False
×
314
        flags = Flag.objects.filter(name="free_phones")
1✔
315
        for flag in flags:
1!
316
            if flag.is_active_for_user(self.user):
×
317
                return True
×
318
        user_subscriptions = self.fxa.extra_data.get("subscriptions", [])
1✔
319
        for sub in settings.SUBSCRIPTIONS_WITH_PHONE:
1✔
320
            if sub in user_subscriptions:
1✔
321
                return True
1✔
322
        return False
1✔
323

324
    @property
1✔
325
    def has_vpn(self):
1✔
326
        if not self.fxa:
×
327
            return False
×
328
        user_subscriptions = self.fxa.extra_data.get("subscriptions", [])
×
329
        for sub in settings.SUBSCRIPTIONS_WITH_VPN:
×
330
            if sub in user_subscriptions:
×
331
                return True
×
332
        return False
×
333

334
    @property
1✔
335
    def emails_forwarded(self):
1✔
336
        return (
×
337
            sum(ra.num_forwarded for ra in self.relay_addresses)
338
            + sum(da.num_forwarded for da in self.domain_addresses)
339
            + self.num_email_forwarded_in_deleted_address
340
        )
341

342
    @property
1✔
343
    def emails_blocked(self):
1✔
344
        return (
×
345
            sum(ra.num_blocked for ra in self.relay_addresses)
346
            + sum(da.num_blocked for da in self.domain_addresses)
347
            + self.num_email_blocked_in_deleted_address
348
        )
349

350
    @property
1✔
351
    def emails_replied(self):
1✔
352
        # Once Django is on version 4.0 and above, we can set the default=0
353
        # and return a int instead of None
354
        # https://docs.djangoproject.com/en/4.0/ref/models/querysets/#default
355
        totals = [self.relay_addresses.aggregate(models.Sum("num_replied"))]
1✔
356
        totals.append(self.domain_addresses.aggregate(models.Sum("num_replied")))
1✔
357
        total_num_replied = 0
1✔
358
        for num in totals:
1✔
359
            total_num_replied += (
1✔
360
                num.get("num_replied__sum") if num.get("num_replied__sum") else 0
361
            )
362
        return total_num_replied + self.num_email_replied_in_deleted_address
1✔
363

364
    @property
1✔
365
    def level_one_trackers_blocked(self):
1✔
366
        return (
×
367
            sum(ra.num_level_one_trackers_blocked or 0 for ra in self.relay_addresses)
368
            + sum(
369
                da.num_level_one_trackers_blocked or 0 for da in self.domain_addresses
370
            )
371
            + (self.num_level_one_trackers_blocked_in_deleted_address or 0)
372
        )
373

374
    @property
1✔
375
    def joined_before_premium_release(self):
1✔
376
        date_created = self.user.date_joined
1✔
377
        return date_created < datetime.fromisoformat("2021-10-22 17:00:00+00:00")
1✔
378

379
    def add_subdomain(self, subdomain):
1✔
380
        # subdomain must be all lowercase
381
        subdomain = subdomain.lower()
1✔
382
        if not self.has_premium:
1✔
383
            raise CannotMakeSubdomainException("error-premium-set-subdomain")
1✔
384
        if self.subdomain is not None:
1✔
385
            raise CannotMakeSubdomainException("error-premium-cannot-change-subdomain")
1✔
386
        self.subdomain = subdomain
1✔
387
        self.full_clean()
1✔
388
        self.save()
1✔
389

390
        RegisteredSubdomain.objects.create(subdomain_hash=hash_subdomain(subdomain))
1✔
391
        return subdomain
1✔
392

393
    def update_abuse_metric(
1✔
394
        self,
395
        address_created=False,
396
        replied=False,
397
        email_forwarded=False,
398
        forwarded_email_size=0,
399
    ):
400
        #  TODO: this should be wrapped in atomic to ensure race conditions are properly handled
401
        # look for abuse metrics created on the same UTC date, regardless of time.
402
        midnight_utc_today = datetime.combine(
1✔
403
            datetime.now(timezone.utc).date(), datetime.min.time()
404
        ).astimezone(timezone.utc)
405
        midnight_utc_tomorow = midnight_utc_today + timedelta(days=1)
1✔
406
        abuse_metric = self.user.abusemetrics_set.filter(
1✔
407
            first_recorded__gte=midnight_utc_today,
408
            first_recorded__lt=midnight_utc_tomorow,
409
        ).first()
410
        if not abuse_metric:
1✔
411
            abuse_metric = AbuseMetrics.objects.create(user=self.user)
1✔
412
            AbuseMetrics.objects.filter(first_recorded__lt=midnight_utc_today).delete()
1✔
413

414
        # increment the abuse metric
415
        if address_created:
1✔
416
            abuse_metric.num_address_created_per_day += 1
1✔
417
        if replied:
1✔
418
            abuse_metric.num_replies_per_day += 1
1✔
419
        if email_forwarded:
1✔
420
            abuse_metric.num_email_forwarded_per_day += 1
1✔
421
        if forwarded_email_size > 0:
1✔
422
            abuse_metric.forwarded_email_size_per_day += forwarded_email_size
1✔
423
        abuse_metric.last_recorded = datetime.now(timezone.utc)
1✔
424
        abuse_metric.save()
1✔
425

426
        # check user should be flagged for abuse
427
        hit_max_create = False
1✔
428
        hit_max_replies = False
1✔
429
        hit_max_forwarded = False
1✔
430
        hit_max_forwarded_email_size = False
1✔
431

432
        hit_max_create = (
1✔
433
            abuse_metric.num_address_created_per_day
434
            >= settings.MAX_ADDRESS_CREATION_PER_DAY
435
        )
436
        hit_max_replies = (
1✔
437
            abuse_metric.num_replies_per_day >= settings.MAX_REPLIES_PER_DAY
438
        )
439
        hit_max_forwarded = (
1✔
440
            abuse_metric.num_email_forwarded_per_day >= settings.MAX_FORWARDED_PER_DAY
441
        )
442
        hit_max_forwarded_email_size = (
1✔
443
            abuse_metric.forwarded_email_size_per_day
444
            >= settings.MAX_FORWARDED_EMAIL_SIZE_PER_DAY
445
        )
446
        if (
1✔
447
            hit_max_create
448
            or hit_max_replies
449
            or hit_max_forwarded
450
            or hit_max_forwarded_email_size
451
        ):
452
            self.last_account_flagged = datetime.now(timezone.utc)
1✔
453
            self.save()
1✔
454
            data = {
1✔
455
                "uid": self.fxa.uid,
456
                "flagged": self.last_account_flagged.timestamp(),
457
                "replies": abuse_metric.num_replies_per_day,
458
                "addresses": abuse_metric.num_address_created_per_day,
459
                "forwarded": abuse_metric.num_email_forwarded_per_day,
460
                "forwarded_size_in_bytes": abuse_metric.forwarded_email_size_per_day,
461
            }
462
            # log for further secops review
463
            abuse_logger.info("Abuse flagged", extra=data)
1✔
464
        return self.last_account_flagged
1✔
465

466
    @property
1✔
467
    def is_flagged(self):
1✔
468
        if not self.last_account_flagged:
1✔
469
            return False
1✔
470
        account_premium_feature_resumed = self.last_account_flagged + timedelta(
1✔
471
            days=settings.PREMIUM_FEATURE_PAUSED_DAYS
472
        )
473
        if datetime.now(timezone.utc) > account_premium_feature_resumed:
1!
474
            # premium feature has been resumed
475
            return False
×
476
        # user was flagged and the premium feature pause period is not yet over
477
        return True
1✔
478

479

480
@receiver(models.signals.post_save, sender=Profile)
1✔
481
def copy_auth_token(sender, instance=None, created=False, **kwargs):
1✔
482
    if created:
1✔
483
        # baker triggers created during tests
484
        # so first check the user doesn't already have a Token
485
        try:
1✔
486
            Token.objects.get(user=instance.user)
1✔
487
            return
1✔
488
        except Token.DoesNotExist:
1✔
489
            Token.objects.create(user=instance.user, key=instance.api_token)
1✔
490

491

492
def address_hash(address, subdomain=None, domain=None):
1✔
493
    if not domain:
1✔
494
        domain = get_domains_from_settings()["MOZMAIL_DOMAIN"]
1✔
495
    if subdomain:
1✔
496
        return sha256(f"{address}@{subdomain}.{domain}".encode("utf-8")).hexdigest()
1✔
497
    if domain == settings.RELAY_FIREFOX_DOMAIN:
1✔
498
        return sha256(f"{address}".encode("utf-8")).hexdigest()
1✔
499
    return sha256(f"{address}@{domain}".encode("utf-8")).hexdigest()
1✔
500

501

502
def address_default():
1✔
503
    return "".join(random.choices(string.ascii_lowercase + string.digits, k=9))
1✔
504

505

506
def has_bad_words(value):
1✔
507
    for badword in emails_config.badwords:
1✔
508
        badword = badword.strip()
1✔
509
        if len(badword) <= 4 and badword == value:
1✔
510
            return True
1✔
511
        if len(badword) > 4 and badword in value:
1✔
512
            return True
1✔
513
    return False
1✔
514

515

516
def is_blocklisted(value):
1✔
517
    return any(blockedword == value for blockedword in emails_config.blocklist)
1✔
518

519

520
def get_domain_numerical(domain_address):
1✔
521
    # get domain name from the address
522
    domains = get_domains_from_settings()
1✔
523
    domains_keys = list(domains.keys())
1✔
524
    domains_values = list(domains.values())
1✔
525
    domain_name = domains_keys[domains_values.index(domain_address)]
1✔
526
    # get domain numerical value from domain name
527
    choices = dict(DOMAIN_CHOICES)
1✔
528
    choices_keys = list(choices.keys())
1✔
529
    choices_values = list(choices.values())
1✔
530
    return choices_keys[choices_values.index(domain_name)]
1✔
531

532

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

536

537
class RegisteredSubdomain(models.Model):
1✔
538
    subdomain_hash = models.CharField(max_length=64, db_index=True, unique=True)
1✔
539
    registered_at = models.DateTimeField(auto_now_add=True)
1✔
540

541
    def __str__(self):
1✔
542
        return self.subdomain_hash
×
543

544

545
class CannotMakeSubdomainException(BadRequest):
1✔
546
    """Exception raised by Profile due to error on subdomain creation.
547

548
    Attributes:
549
        message -- optional explanation of the error
550
    """
551

552
    def __init__(self, message=None):
1✔
553
        self.message = message
1✔
554

555

556
class CannotMakeAddressException(RelayAPIException):
1✔
557
    """Base exception for RelayAddress or DomainAddress creation failure."""
558

559

560
class AccountIsPausedException(CannotMakeAddressException):
1✔
561
    default_code = "account_is_paused"
1✔
562
    default_detail = "Your account is on pause."
1✔
563
    status_code = 403
1✔
564

565

566
class RelayAddrFreeTierLimitException(CannotMakeAddressException):
1✔
567
    default_code = "free_tier_limit"
1✔
568
    default_detail_template = (
1✔
569
        "You’ve used all {free_tier_limit} email masks included with your free account."
570
        "You can reuse an existing mask, but using a unique mask for each account is the most secure option."
571
    )
572
    status_code = 403
1✔
573

574
    def __init__(self, free_tier_limit: Optional[int] = None, *args, **kwargs):
1✔
575
        self.free_tier_limit = free_tier_limit or settings.MAX_NUM_FREE_ALIASES
1✔
576
        self.default_detail = self.default_detail_template.format(
1✔
577
            free_tier_limit=self.free_tier_limit
578
        )
579
        super().__init__(*args, **kwargs)
1✔
580

581
    def error_context(self) -> ErrorContextType:
1✔
582
        return {"free_tier_limit": self.free_tier_limit}
1✔
583

584

585
class DomainAddrFreeTierException(CannotMakeAddressException):
1✔
586
    default_code = "free_tier_no_subdomain_masks"
1✔
587
    default_detail = "Your free account does not include custom subdomains for masks. To create custom masks, upgrade to Relay Premium."
1✔
588
    status_code = 403
1✔
589

590

591
class DomainAddrNeedSubdomainException(CannotMakeAddressException):
1✔
592
    default_code = "need_subdomain"
1✔
593
    default_detail = "Please select a subdomain before creating a custom email address."
1✔
594
    status_code = 400
1✔
595

596

597
class DomainAddrUnavailableException(CannotMakeAddressException):
1✔
598
    default_code = "address_unavailable"
1✔
599
    default_detail_template = "“{unavailable_address}” could not be created. Please try again with a different mask name."
1✔
600
    status_code = 400
1✔
601

602
    def __init__(self, unavailable_address: str, *args, **kwargs):
1✔
603
        self.unavailable_address = unavailable_address
1✔
604
        self.default_detail = self.default_detail_template.format(
1✔
605
            unavailable_address=self.unavailable_address
606
        )
607
        super().__init__(*args, **kwargs)
1✔
608

609
    def error_context(self) -> ErrorContextType:
1✔
610
        return {"unavailable_address": self.unavailable_address}
1✔
611

612

613
class RelayAddress(models.Model):
1✔
614
    user = models.ForeignKey(User, on_delete=models.CASCADE)
1✔
615
    address = models.CharField(max_length=64, default=address_default, unique=True)
1✔
616
    domain = models.PositiveSmallIntegerField(
1✔
617
        choices=DOMAIN_CHOICES, default=default_domain_numerical
618
    )
619
    enabled = models.BooleanField(default=True)
1✔
620
    description = models.CharField(max_length=64, blank=True)
1✔
621
    created_at = models.DateTimeField(auto_now_add=True, db_index=True)
1✔
622
    last_modified_at = models.DateTimeField(auto_now=True, db_index=True)
1✔
623
    last_used_at = models.DateTimeField(blank=True, null=True)
1✔
624
    num_forwarded = models.PositiveIntegerField(default=0)
1✔
625
    num_blocked = models.PositiveIntegerField(default=0)
1✔
626
    num_level_one_trackers_blocked = models.PositiveIntegerField(default=0, null=True)
1✔
627
    num_replied = models.PositiveIntegerField(default=0)
1✔
628
    num_spam = models.PositiveIntegerField(default=0)
1✔
629
    generated_for = models.CharField(max_length=255, blank=True)
1✔
630
    block_list_emails = models.BooleanField(default=False)
1✔
631
    used_on = models.TextField(default=None, blank=True, null=True)
1✔
632

633
    def __str__(self):
1✔
634
        return self.address
×
635

636
    def delete(self, *args, **kwargs):
1✔
637
        # TODO: create hard bounce receipt rule in AWS for the address
638
        deleted_address = DeletedAddress.objects.create(
1✔
639
            address_hash=address_hash(self.address, domain=self.domain_value),
640
            num_forwarded=self.num_forwarded,
641
            num_blocked=self.num_blocked,
642
            num_replied=self.num_replied,
643
            num_spam=self.num_spam,
644
        )
645
        deleted_address.save()
1✔
646
        profile = Profile.objects.get(user=self.user)
1✔
647
        profile.address_last_deleted = datetime.now(timezone.utc)
1✔
648
        profile.num_address_deleted += 1
1✔
649
        profile.num_email_forwarded_in_deleted_address += self.num_forwarded
1✔
650
        profile.num_email_blocked_in_deleted_address += self.num_blocked
1✔
651
        profile.num_level_one_trackers_blocked_in_deleted_address = (
1✔
652
            profile.num_level_one_trackers_blocked_in_deleted_address or 0
653
        ) + (self.num_level_one_trackers_blocked or 0)
654
        profile.num_email_replied_in_deleted_address += self.num_replied
1✔
655
        profile.num_email_spam_in_deleted_address += self.num_spam
1✔
656
        profile.save()
1✔
657
        return super(RelayAddress, self).delete(*args, **kwargs)
1✔
658

659
    def save(self, *args, **kwargs):
1✔
660
        if self._state.adding:
1✔
661
            with transaction.atomic():
1✔
662
                locked_profile = Profile.objects.select_for_update().get(user=self.user)
1✔
663
                check_user_can_make_another_address(locked_profile)
1✔
664
                while True:
1✔
665
                    if valid_address(self.address, self.domain):
1!
666
                        break
1✔
667
                    self.address = address_default()
×
668
                locked_profile.update_abuse_metric(address_created=True)
1✔
669
        if not self.user.profile.server_storage:
1✔
670
            self.description = ""
1✔
671
            self.generated_for = ""
1✔
672
            self.used_on = ""
1✔
673
        return super().save(*args, **kwargs)
1✔
674

675
    @property
1✔
676
    def domain_value(self):
1✔
677
        return get_domains_from_settings().get(self.get_domain_display())
1✔
678

679
    @property
1✔
680
    def full_address(self):
1✔
681
        return "%s@%s" % (self.address, self.domain_value)
1✔
682

683

684
def check_user_can_make_another_address(profile: Profile) -> None:
1✔
685
    if profile.is_flagged:
1✔
686
        raise AccountIsPausedException()
1✔
687
    if profile.at_max_free_aliases and not profile.has_premium:
1✔
688
        raise RelayAddrFreeTierLimitException()
1✔
689

690

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

697

698
def valid_address(address, domain):
1✔
699
    address_pattern_valid = valid_address_pattern(address)
1✔
700
    address_contains_badword = has_bad_words(address)
1✔
701
    address_is_blocklisted = is_blocklisted(address)
1✔
702
    address_already_deleted = DeletedAddress.objects.filter(
1✔
703
        address_hash=address_hash(address, domain=domain)
704
    ).count()
705
    if (
1✔
706
        address_already_deleted > 0
707
        or address_contains_badword
708
        or address_is_blocklisted
709
        or not address_pattern_valid
710
    ):
711
        return False
1✔
712
    return True
1✔
713

714

715
class DeletedAddress(models.Model):
1✔
716
    address_hash = models.CharField(max_length=64, db_index=True)
1✔
717
    num_forwarded = models.PositiveIntegerField(default=0)
1✔
718
    num_blocked = models.PositiveIntegerField(default=0)
1✔
719
    num_replied = models.PositiveIntegerField(default=0)
1✔
720
    num_spam = models.PositiveIntegerField(default=0)
1✔
721

722
    def __str__(self):
1✔
723
        return self.address_hash
×
724

725

726
def check_user_can_make_domain_address(user_profile: Profile) -> None:
1✔
727
    if not user_profile.has_premium:
1✔
728
        raise DomainAddrFreeTierException()
1✔
729

730
    if not user_profile.subdomain:
1✔
731
        raise DomainAddrNeedSubdomainException()
1✔
732

733
    if user_profile.is_flagged:
1✔
734
        raise AccountIsPausedException()
1✔
735

736

737
class DomainAddress(models.Model):
1✔
738
    user = models.ForeignKey(User, on_delete=models.CASCADE)
1✔
739
    address = models.CharField(
1✔
740
        max_length=64, validators=[MinLengthValidator(limit_value=1)]
741
    )
742
    enabled = models.BooleanField(default=True)
1✔
743
    description = models.CharField(max_length=64, blank=True)
1✔
744
    domain = models.PositiveSmallIntegerField(choices=DOMAIN_CHOICES, default=2)
1✔
745
    created_at = models.DateTimeField(auto_now_add=True, db_index=True)
1✔
746
    first_emailed_at = models.DateTimeField(null=True, db_index=True)
1✔
747
    last_modified_at = models.DateTimeField(auto_now=True, db_index=True)
1✔
748
    last_used_at = models.DateTimeField(blank=True, null=True)
1✔
749
    num_forwarded = models.PositiveIntegerField(default=0)
1✔
750
    num_blocked = models.PositiveIntegerField(default=0)
1✔
751
    num_level_one_trackers_blocked = models.PositiveIntegerField(default=0, null=True)
1✔
752
    num_replied = models.PositiveIntegerField(default=0)
1✔
753
    num_spam = models.PositiveIntegerField(default=0)
1✔
754
    block_list_emails = models.BooleanField(default=False)
1✔
755
    used_on = models.TextField(default=None, blank=True, null=True)
1✔
756

757
    class Meta:
1✔
758
        unique_together = ["user", "address"]
1✔
759

760
    def __str__(self):
1✔
761
        return self.address
×
762

763
    def save(self, *args, **kwargs) -> None:
1✔
764
        user_profile = self.user.profile
1✔
765
        if self._state.adding:
1✔
766
            check_user_can_make_domain_address(user_profile)
1✔
767
            pattern_valid = valid_address_pattern(self.address)
1✔
768
            address_contains_badword = has_bad_words(self.address)
1✔
769
            if not pattern_valid or address_contains_badword:
1✔
770
                raise DomainAddrUnavailableException(unavailable_address=self.address)
1✔
771
            user_profile.update_abuse_metric(address_created=True)
1✔
772
        # TODO: validate user is premium to set block_list_emails
773
        if not user_profile.server_storage:
1✔
774
            self.description = ""
1✔
775
        return super().save(*args, **kwargs)
1✔
776

777
    @property
1✔
778
    def user_profile(self):
1✔
779
        return Profile.objects.get(user=self.user)
1✔
780

781
    @staticmethod
1✔
782
    def make_domain_address(user_profile, address=None, made_via_email=False):
1✔
783
        check_user_can_make_domain_address(user_profile)
1✔
784

785
        address_contains_badword = False
1✔
786
        if not address:
1✔
787
            # FIXME: if the alias is randomly generated and has bad words
788
            # we should retry like make_relay_address does
789
            # not fixing this now because not sure randomly generated
790
            # DomainAlias will be a feature
791
            address = address_default()
1✔
792
            # Only check for bad words if randomly generated
793

794
        domain_address = DomainAddress.objects.create(
1✔
795
            user=user_profile.user,
796
            address=address,
797
        )
798
        if made_via_email:
1✔
799
            # update first_emailed_at indicating alias generation impromptu.
800
            domain_address.first_emailed_at = datetime.now(timezone.utc)
1✔
801
            domain_address.save()
1✔
802
        return domain_address
1✔
803

804
    def delete(self, *args, **kwargs):
1✔
805
        # TODO: create hard bounce receipt rule in AWS for the address
806
        deleted_address = DeletedAddress.objects.create(
1✔
807
            address_hash=address_hash(
808
                self.address, self.user_profile.subdomain, self.domain_value
809
            ),
810
            num_forwarded=self.num_forwarded,
811
            num_blocked=self.num_blocked,
812
            num_replied=self.num_replied,
813
            num_spam=self.num_spam,
814
        )
815
        deleted_address.save()
1✔
816
        # self.user_profile is a property and should not be used to
817
        # update values on the user's profile
818
        profile = Profile.objects.get(user=self.user)
1✔
819
        profile.address_last_deleted = datetime.now(timezone.utc)
1✔
820
        profile.num_address_deleted += 1
1✔
821
        profile.num_email_forwarded_in_deleted_address += self.num_forwarded
1✔
822
        profile.num_email_blocked_in_deleted_address += self.num_blocked
1✔
823
        profile.num_level_one_trackers_blocked_in_deleted_address = (
1✔
824
            profile.num_level_one_trackers_blocked_in_deleted_address or 0
825
        ) + (self.num_level_one_trackers_blocked or 0)
826
        profile.num_email_replied_in_deleted_address += self.num_replied
1✔
827
        profile.num_email_spam_in_deleted_address += self.num_spam
1✔
828
        profile.save()
1✔
829
        return super(DomainAddress, self).delete(*args, **kwargs)
1✔
830

831
    @property
1✔
832
    def domain_value(self):
1✔
833
        return get_domains_from_settings().get(self.get_domain_display())
1✔
834

835
    @property
1✔
836
    def full_address(self):
1✔
837
        return "%s@%s.%s" % (
1✔
838
            self.address,
839
            self.user_profile.subdomain,
840
            self.domain_value,
841
        )
842

843

844
class Reply(models.Model):
1✔
845
    relay_address = models.ForeignKey(
1✔
846
        RelayAddress, on_delete=models.CASCADE, blank=True, null=True
847
    )
848
    domain_address = models.ForeignKey(
1✔
849
        DomainAddress, on_delete=models.CASCADE, blank=True, null=True
850
    )
851
    lookup = models.CharField(max_length=255, blank=False, db_index=True)
1✔
852
    encrypted_metadata = models.TextField(blank=False)
1✔
853
    created_at = models.DateField(auto_now_add=True, null=False, db_index=True)
1✔
854

855
    @property
1✔
856
    def address(self):
1✔
857
        return self.relay_address or self.domain_address
1✔
858

859
    @property
1✔
860
    def profile(self):
1✔
861
        return self.address.user.profile
1✔
862

863
    @property
1✔
864
    def owner_has_premium(self):
1✔
865
        return self.profile.has_premium
1✔
866

867
    def increment_num_replied(self):
1✔
868
        address = self.relay_address or self.domain_address
1✔
869
        address.num_replied += 1
1✔
870
        address.last_used_at = datetime.now(timezone.utc)
1✔
871
        address.save(update_fields=["num_replied", "last_used_at"])
1✔
872
        return address.num_replied
1✔
873

874

875
class AbuseMetrics(models.Model):
1✔
876
    user = models.ForeignKey(User, on_delete=models.CASCADE)
1✔
877
    first_recorded = models.DateTimeField(auto_now_add=True, db_index=True)
1✔
878
    last_recorded = models.DateTimeField(auto_now_add=True, db_index=True)
1✔
879
    num_address_created_per_day = models.PositiveSmallIntegerField(default=0)
1✔
880
    num_replies_per_day = models.PositiveSmallIntegerField(default=0)
1✔
881
    # Values from 0 to 32767 are safe in all databases supported by Django.
882
    num_email_forwarded_per_day = models.PositiveSmallIntegerField(default=0)
1✔
883
    # Values from 0 to 9223372036854775807 are safe in all databases supported by Django.
884
    forwarded_email_size_per_day = models.PositiveBigIntegerField(default=0)
1✔
885

886
    class Meta:
1✔
887
        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