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

mozilla / fx-private-relay / de78a40c-1b6b-4b1a-98c3-56eb1add96d7

22 Jul 2025 07:58PM UTC coverage: 86.268% (-0.02%) from 86.285%
de78a40c-1b6b-4b1a-98c3-56eb1add96d7

Pull #5742

circleci

joeherm
MPP-4033: Fix Relay user replies to be case insensitve to their stored email
Pull Request #5742: MPP-4033: Fix Relay user replies to be case insensitve to their stored email

2729 of 3936 branches covered (69.33%)

Branch coverage included in aggregate %.

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

42 existing lines in 5 files now uncovered.

17851 of 19920 relevant lines covered (89.61%)

9.98 hits per line

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

91.23
/privaterelay/models.py
1
from __future__ import annotations
1✔
2

3
import logging
1✔
4
import uuid
1✔
5
from collections import namedtuple
1✔
6
from datetime import UTC, datetime, timedelta
1✔
7
from hashlib import sha256
1✔
8
from typing import TYPE_CHECKING, Literal
1✔
9

10
from django.conf import settings
1✔
11
from django.contrib.auth.models import User
1✔
12
from django.db import models, transaction
1✔
13
from django.utils.translation.trans_real import (
1✔
14
    get_supported_language_variant,
15
    parse_accept_lang_header,
16
)
17

18
from allauth.socialaccount.models import SocialAccount
1✔
19

20
from .country_utils import AcceptLanguageError, guess_country_from_accept_lang
1✔
21
from .exceptions import CannotMakeSubdomainException
1✔
22
from .plans import get_premium_countries
1✔
23
from .utils import flag_is_active_in_task
1✔
24
from .validators import valid_available_subdomain
1✔
25

26
if TYPE_CHECKING:
27
    from collections.abc import Iterable
28

29
    from django.db.models.base import ModelBase
30
    from django.db.models.query import QuerySet
31

32
    from emails.models import DomainAddress, RelayAddress
33

34

35
abuse_logger = logging.getLogger("abusemetrics")
1✔
36
BounceStatus = namedtuple("BounceStatus", "paused type")
1✔
37
PREMIUM_DOMAINS = ["mozilla.com", "getpocket.com", "mozillafoundation.org"]
1✔
38

39

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

43

44
class Profile(models.Model):
1✔
45
    user = models.OneToOneField(User, on_delete=models.CASCADE)
1✔
46
    api_token = models.UUIDField(default=uuid.uuid4)
1✔
47
    num_address_deleted = models.PositiveIntegerField(default=0)
1✔
48
    date_subscribed = models.DateTimeField(blank=True, null=True)
1✔
49
    date_subscribed_phone = models.DateTimeField(blank=True, null=True)
1✔
50
    # TODO MPP-2972: delete date_phone_subscription_checked in favor of
51
    # date_phone_subscription_next_reset
52
    date_phone_subscription_checked = models.DateTimeField(blank=True, null=True)
1✔
53
    date_phone_subscription_start = models.DateTimeField(blank=True, null=True)
1✔
54
    date_phone_subscription_reset = models.DateTimeField(blank=True, null=True)
1✔
55
    date_phone_subscription_end = models.DateTimeField(blank=True, null=True)
1✔
56
    address_last_deleted = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
57
    last_soft_bounce = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
58
    last_hard_bounce = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
59
    last_account_flagged = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
60
    num_deleted_relay_addresses = models.PositiveIntegerField(default=0)
1✔
61
    num_deleted_domain_addresses = models.PositiveIntegerField(default=0)
1✔
62
    num_email_forwarded_in_deleted_address = models.PositiveIntegerField(default=0)
1✔
63
    num_email_blocked_in_deleted_address = models.PositiveIntegerField(default=0)
1✔
64
    num_level_one_trackers_blocked_in_deleted_address = models.PositiveIntegerField(
1✔
65
        default=0, null=True
66
    )
67
    num_email_replied_in_deleted_address = models.PositiveIntegerField(default=0)
1✔
68
    num_email_spam_in_deleted_address = models.PositiveIntegerField(default=0)
1✔
69
    subdomain = models.CharField(
1✔
70
        blank=True,
71
        null=True,
72
        unique=True,
73
        max_length=63,
74
        db_index=True,
75
        validators=[valid_available_subdomain],
76
    )
77
    # Whether we store the user's alias labels in the server
78
    server_storage = models.BooleanField(default=True)
1✔
79
    # Whether we store the caller/sender log for the user's relay number
80
    store_phone_log = models.BooleanField(default=True)
1✔
81
    # TODO: Data migration to set null to false
82
    # TODO: Schema migration to remove null=True
83
    remove_level_one_email_trackers = models.BooleanField(null=True, default=False)
1✔
84
    onboarding_state = models.PositiveIntegerField(default=0)
1✔
85
    onboarding_free_state = models.PositiveIntegerField(default=0)
1✔
86
    auto_block_spam = models.BooleanField(default=False)
1✔
87
    forwarded_first_reply = models.BooleanField(default=False)
1✔
88
    # Empty string means the profile was created through relying party flow
89
    created_by = models.CharField(blank=True, null=True, max_length=63)
1✔
90
    sent_welcome_email = models.BooleanField(default=False)
1✔
91
    last_engagement = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
92

93
    class Meta:
1✔
94
        # Moved from emails to privaterelay, but old table name retained. See:
95
        # privaterelay/migrations/0010_move_profile_and_registered_subdomain_models.py
96
        # emails/migrations/0062_move_profile_and_registered_subdomain_models.py
97
        db_table = "emails_profile"
1✔
98

99
    def __str__(self):
1✔
100
        return f"{self.user} Profile"
1✔
101

102
    def save(
1✔
103
        self,
104
        force_insert: bool | tuple[ModelBase, ...] = False,
105
        force_update: bool = False,
106
        using: str | None = None,
107
        update_fields: Iterable[str] | None = None,
108
    ) -> None:
109
        from emails.models import DomainAddress, RelayAddress
1✔
110

111
        # always lower-case the subdomain before saving it
112
        # TODO: change subdomain field as a custom field inheriting from
113
        # CharField to validate constraints on the field update too
114
        if self.subdomain and not self.subdomain.islower():
1✔
115
            self.subdomain = self.subdomain.lower()
1✔
116
            if update_fields is not None:
1✔
117
                update_fields = {"subdomain"}.union(update_fields)
1✔
118
        super().save(
1✔
119
            force_insert=force_insert,
120
            force_update=force_update,
121
            using=using,
122
            update_fields=update_fields,
123
        )
124
        # any time a profile is saved with server_storage False, delete the
125
        # appropriate server-stored Relay address data.
126
        if not self.server_storage:
1✔
127
            relay_addresses = RelayAddress.objects.filter(user=self.user)
1✔
128
            relay_addresses.update(description="", generated_for="", used_on="")
1✔
129
            domain_addresses = DomainAddress.objects.filter(user=self.user)
1✔
130
            domain_addresses.update(description="", used_on="")
1✔
131
        if settings.PHONES_ENABLED:
1!
132
            # any time a profile is saved with store_phone_log False, delete the
133
            # appropriate server-stored InboundContact records
134
            from phones.models import InboundContact, RelayNumber
1✔
135

136
            if not self.store_phone_log:
1✔
137
                try:
1✔
138
                    relay_number = RelayNumber.objects.get(user=self.user)
1✔
139
                    InboundContact.objects.filter(relay_number=relay_number).delete()
1✔
140
                except RelayNumber.DoesNotExist:
1✔
141
                    pass
1✔
142

143
    @property
1✔
144
    def language(self):
1✔
145
        if self.fxa and self.fxa.extra_data.get("locale"):
1✔
146
            for accept_lang, _ in parse_accept_lang_header(
1!
147
                self.fxa.extra_data.get("locale")
148
            ):
149
                try:
1✔
150
                    return get_supported_language_variant(accept_lang)
1✔
151
                except LookupError:
×
152
                    continue
×
153
        return "en"
1✔
154

155
    # This method returns whether the locale associated with the user's Mozilla account
156
    # includes a country code from a Premium country. This is less accurate than using
157
    # get_countries_info_from_request_and_mapping(), which can use a GeoIP lookup, so
158
    # prefer using that if a request context is available. In other contexts, for
159
    # example when sending an email, this method can be useful.
160
    @property
1✔
161
    def fxa_locale_in_premium_country(self) -> bool:
1✔
162
        if self.fxa and self.fxa.extra_data.get("locale"):
1✔
163
            try:
1✔
164
                country = guess_country_from_accept_lang(self.fxa.extra_data["locale"])
1✔
165
            except AcceptLanguageError:
1✔
166
                return False
1✔
167
            premium_countries = get_premium_countries()
1✔
168
            if country in premium_countries:
1✔
169
                return True
1✔
170
        return False
1✔
171

172
    @property
1✔
173
    def avatar(self) -> str | None:
1✔
174
        if fxa := self.fxa:
1!
175
            return str(fxa.extra_data.get("avatar"))
1✔
176
        return None
×
177

178
    @property
1✔
179
    def relay_addresses(self) -> QuerySet[RelayAddress]:
1✔
180
        from emails.models import RelayAddress
1✔
181

182
        return RelayAddress.objects.filter(user=self.user)
1✔
183

184
    @property
1✔
185
    def domain_addresses(self) -> QuerySet[DomainAddress]:
1✔
186
        from emails.models import DomainAddress
1✔
187

188
        return DomainAddress.objects.filter(user=self.user)
1✔
189

190
    @property
1✔
191
    def total_masks(self) -> int:
1✔
192
        ra_count: int = self.relay_addresses.count()
1✔
193
        da_count: int = self.domain_addresses.count()
1✔
194
        return ra_count + da_count
1✔
195

196
    @property
1✔
197
    def at_mask_limit(self) -> bool:
1✔
198
        if self.has_premium:
1✔
199
            return False
1✔
200
        ra_count: int = self.relay_addresses.count()
1✔
201
        return ra_count >= settings.MAX_NUM_FREE_ALIASES
1✔
202

203
    def check_bounce_pause(self) -> BounceStatus:
1✔
204
        if self.last_hard_bounce:
1✔
205
            last_hard_bounce_allowed = datetime.now(UTC) - timedelta(
1✔
206
                days=settings.HARD_BOUNCE_ALLOWED_DAYS
207
            )
208
            if self.last_hard_bounce > last_hard_bounce_allowed:
1✔
209
                return BounceStatus(True, "hard")
1✔
210
            self.last_hard_bounce = None
1✔
211
            self.save()
1✔
212
        if self.last_soft_bounce:
1✔
213
            last_soft_bounce_allowed = datetime.now(UTC) - timedelta(
1✔
214
                days=settings.SOFT_BOUNCE_ALLOWED_DAYS
215
            )
216
            if self.last_soft_bounce > last_soft_bounce_allowed:
1✔
217
                return BounceStatus(True, "soft")
1✔
218
            self.last_soft_bounce = None
1✔
219
            self.save()
1✔
220
        return BounceStatus(False, "")
1✔
221

222
    @property
1✔
223
    def bounce_status(self) -> BounceStatus:
1✔
224
        return self.check_bounce_pause()
1✔
225

226
    @property
1✔
227
    def next_email_try(self) -> datetime:
1✔
228
        bounce_pause, bounce_type = self.check_bounce_pause()
1✔
229

230
        if not bounce_pause:
1✔
231
            return datetime.now(UTC)
1✔
232

233
        if bounce_type == "soft":
1✔
234
            if not self.last_soft_bounce:
1!
235
                raise ValueError("self.last_soft_bounce must be truthy value.")
×
236
            return self.last_soft_bounce + timedelta(
1✔
237
                days=settings.SOFT_BOUNCE_ALLOWED_DAYS
238
            )
239

240
        if bounce_type != "hard":
1!
241
            raise ValueError("bounce_type must be either 'soft' or 'hard'")
×
242
        if not self.last_hard_bounce:
1!
243
            raise ValueError("self.last_hard_bounce must be truthy value.")
×
244
        return self.last_hard_bounce + timedelta(days=settings.HARD_BOUNCE_ALLOWED_DAYS)
1✔
245

246
    @property
1✔
247
    def last_bounce_date(self):
1✔
248
        if self.last_hard_bounce:
1✔
249
            return self.last_hard_bounce
1✔
250
        if self.last_soft_bounce:
1✔
251
            return self.last_soft_bounce
1✔
252
        return None
1✔
253

254
    @property
1✔
255
    def at_max_free_aliases(self) -> bool:
1✔
256
        relay_addresses_count: int = self.relay_addresses.count()
1✔
257
        return relay_addresses_count >= settings.MAX_NUM_FREE_ALIASES
1✔
258

259
    @property
1✔
260
    def fxa(self) -> SocialAccount | None:
1✔
261
        # Note: we are NOT using .filter() here because it invalidates
262
        # any profile instances that were queried with prefetch_related, which
263
        # we use in at least the profile view to minimize queries
264
        if not hasattr(self.user, "socialaccount_set"):
1!
265
            raise AttributeError("self.user must have socialaccount_set attribute")
×
266
        for sa in self.user.socialaccount_set.all():
1✔
267
            if sa.provider == "fxa":
1!
268
                return sa
1✔
269
        return None
1✔
270

271
    @property
1✔
272
    def display_name(self) -> str | None:
1✔
273
        # if display name is not set on FxA the
274
        # displayName key will not exist on the extra_data
275
        if fxa := self.fxa:
1!
276
            name = fxa.extra_data.get("displayName")
1✔
277
            return name if name is None else str(name)
1✔
278
        return None
×
279

280
    @property
1✔
281
    def custom_domain(self) -> str:
1✔
282
        if not self.subdomain:
×
283
            raise ValueError("self.subdomain must be truthy value.")
×
284
        return f"@{self.subdomain}.{settings.MOZMAIL_DOMAIN}"
×
285

286
    @property
1✔
287
    def has_premium(self) -> bool:
1✔
288
        if not self.user.is_active:
1!
289
            return False
×
290

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

304
    @property
1✔
305
    def has_phone(self) -> bool:
1✔
306
        if not self.fxa:
1✔
307
            return False
1✔
308
        if settings.RELAY_CHANNEL != "prod" and not settings.IN_PYTEST:
1!
309
            if not flag_is_active_in_task("phones", self.user):
×
310
                return False
×
311
        if flag_is_active_in_task("free_phones", self.user):
1!
312
            return True
×
313
        user_subscriptions = self.fxa.extra_data.get("subscriptions", [])
1✔
314
        for sub in settings.SUBSCRIPTIONS_WITH_PHONE:
1✔
315
            if sub in user_subscriptions:
1✔
316
                return True
1✔
317
        return False
1✔
318

319
    @property
1✔
320
    def has_vpn(self) -> bool:
1✔
321
        if not self.fxa:
1!
322
            return False
×
323
        user_subscriptions = self.fxa.extra_data.get("subscriptions", [])
1✔
324
        for sub in settings.SUBSCRIPTIONS_WITH_VPN:
1✔
325
            if sub in user_subscriptions:
1✔
326
                return True
1✔
327
        return False
1✔
328

329
    @property
1✔
330
    def emails_forwarded(self) -> int:
1✔
331
        return (
1✔
332
            sum(ra.num_forwarded for ra in self.relay_addresses)
333
            + sum(da.num_forwarded for da in self.domain_addresses)
334
            + self.num_email_forwarded_in_deleted_address
335
        )
336

337
    @property
1✔
338
    def emails_blocked(self) -> int:
1✔
339
        return (
1✔
340
            sum(ra.num_blocked for ra in self.relay_addresses)
341
            + sum(da.num_blocked for da in self.domain_addresses)
342
            + self.num_email_blocked_in_deleted_address
343
        )
344

345
    @property
1✔
346
    def emails_replied(self) -> int:
1✔
347
        ra_sum = self.relay_addresses.aggregate(models.Sum("num_replied", default=0))
1✔
348
        da_sum = self.domain_addresses.aggregate(models.Sum("num_replied", default=0))
1✔
349
        return (
1✔
350
            int(ra_sum["num_replied__sum"])
351
            + int(da_sum["num_replied__sum"])
352
            + self.num_email_replied_in_deleted_address
353
        )
354

355
    @property
1✔
356
    def level_one_trackers_blocked(self) -> int:
1✔
357
        return (
1✔
358
            sum(ra.num_level_one_trackers_blocked or 0 for ra in self.relay_addresses)
359
            + sum(
360
                da.num_level_one_trackers_blocked or 0 for da in self.domain_addresses
361
            )
362
            + (self.num_level_one_trackers_blocked_in_deleted_address or 0)
363
        )
364

365
    @property
1✔
366
    def joined_before_premium_release(self):
1✔
367
        date_created = self.user.date_joined
1✔
368
        return date_created < datetime.fromisoformat("2021-10-22 17:00:00+00:00")
1✔
369

370
    @property
1✔
371
    def date_phone_registered(self) -> datetime | None:
1✔
372
        if not settings.PHONES_ENABLED:
1!
UNCOV
373
            return None
×
374

375
        from phones.models import RealPhone, RelayNumber
1✔
376

377
        try:
1✔
378
            real_phone = RealPhone.verified_objects.get_for_user(self.user)
1✔
379
            relay_number = RelayNumber.objects.get(user=self.user)
1✔
380
        except RealPhone.DoesNotExist:
1✔
381
            return None
1✔
382
        except RelayNumber.DoesNotExist:
1✔
383
            return real_phone.verified_date
1✔
384
        return relay_number.created_at or real_phone.verified_date
1✔
385

386
    def add_subdomain(self, subdomain):
1✔
387
        # Handles if the subdomain is "" or None
388
        if not subdomain:
1✔
389
            raise CannotMakeSubdomainException(
1✔
390
                "error-subdomain-cannot-be-empty-or-null"
391
            )
392

393
        # subdomain must be all lowercase
394
        subdomain = subdomain.lower()
1✔
395

396
        if not self.has_premium:
1✔
397
            raise CannotMakeSubdomainException("error-premium-set-subdomain")
1✔
398
        if self.subdomain is not None:
1✔
399
            raise CannotMakeSubdomainException("error-premium-cannot-change-subdomain")
1✔
400
        self.subdomain = subdomain
1✔
401
        # The validator defined in the subdomain field does not get run in full_clean()
402
        # when self.subdomain is "" or None, so we need to run the validator again to
403
        # catch these cases.
404
        valid_available_subdomain(subdomain)
1✔
405
        self.full_clean()
1✔
406
        self.save()
1✔
407

408
        RegisteredSubdomain.objects.create(subdomain_hash=hash_subdomain(subdomain))
1✔
409
        return subdomain
1✔
410

411
    def update_abuse_metric(
1✔
412
        self,
413
        address_created: bool = False,
414
        replied: bool = False,
415
        email_forwarded: bool = False,
416
        forwarded_email_size: int = 0,
417
    ) -> datetime | None:
418
        if self.user.email in settings.ALLOWED_ACCOUNTS:
1!
UNCOV
419
            return None
×
420

421
        with transaction.atomic():
1✔
422
            # look for abuse metrics created on the same UTC date, regardless of time.
423
            midnight_utc_today = datetime.combine(
1✔
424
                datetime.now(UTC).date(), datetime.min.time()
425
            ).astimezone(UTC)
426
            midnight_utc_tomorrow = midnight_utc_today + timedelta(days=1)
1✔
427
            abuse_metric = (
1✔
428
                self.user.abusemetrics_set.select_for_update()
429
                .filter(
430
                    first_recorded__gte=midnight_utc_today,
431
                    first_recorded__lt=midnight_utc_tomorrow,
432
                )
433
                .first()
434
            )
435
            if not abuse_metric:
1✔
436
                from emails.models import AbuseMetrics
1✔
437

438
                abuse_metric = AbuseMetrics.objects.create(user=self.user)
1✔
439
                AbuseMetrics.objects.filter(
1✔
440
                    first_recorded__lt=midnight_utc_today
441
                ).delete()
442

443
            # increment the abuse metric
444
            if address_created:
1✔
445
                abuse_metric.num_address_created_per_day += 1
1✔
446
            if replied:
1✔
447
                abuse_metric.num_replies_per_day += 1
1✔
448
            if email_forwarded:
1✔
449
                abuse_metric.num_email_forwarded_per_day += 1
1✔
450
            if forwarded_email_size > 0:
1✔
451
                abuse_metric.forwarded_email_size_per_day += forwarded_email_size
1✔
452
            abuse_metric.last_recorded = datetime.now(UTC)
1✔
453
            abuse_metric.save()
1✔
454

455
            # check user should be flagged for abuse
456
            hit_max_create = False
1✔
457
            hit_max_replies = False
1✔
458
            hit_max_forwarded = False
1✔
459
            hit_max_forwarded_email_size = False
1✔
460

461
            hit_max_create = (
1✔
462
                abuse_metric.num_address_created_per_day
463
                >= settings.MAX_ADDRESS_CREATION_PER_DAY
464
            )
465
            hit_max_replies = (
1✔
466
                abuse_metric.num_replies_per_day >= settings.MAX_REPLIES_PER_DAY
467
            )
468
            hit_max_forwarded = (
1✔
469
                abuse_metric.num_email_forwarded_per_day
470
                >= settings.MAX_FORWARDED_PER_DAY
471
            )
472
            hit_max_forwarded_email_size = (
1✔
473
                abuse_metric.forwarded_email_size_per_day
474
                >= settings.MAX_FORWARDED_EMAIL_SIZE_PER_DAY
475
            )
476
            if (
1✔
477
                hit_max_create
478
                or hit_max_replies
479
                or hit_max_forwarded
480
                or hit_max_forwarded_email_size
481
            ):
482
                self.last_account_flagged = datetime.now(UTC)
1✔
483
                self.save()
1✔
484
                data = {
1✔
485
                    "uid": self.fxa.uid if self.fxa else None,
486
                    "flagged": self.last_account_flagged.timestamp(),
487
                    "replies": abuse_metric.num_replies_per_day,
488
                    "addresses": abuse_metric.num_address_created_per_day,
489
                    "forwarded": abuse_metric.num_email_forwarded_per_day,
490
                    "forwarded_size_in_bytes": (
491
                        abuse_metric.forwarded_email_size_per_day
492
                    ),
493
                }
494
                # log for further secops review
495
                abuse_logger.info("Abuse flagged", extra=data)
1✔
496

497
        return self.last_account_flagged
1✔
498

499
    @property
1✔
500
    def is_flagged(self):
1✔
501
        if not self.last_account_flagged:
1✔
502
            return False
1✔
503
        account_premium_feature_resumed = self.last_account_flagged + timedelta(
1✔
504
            days=settings.PREMIUM_FEATURE_PAUSED_DAYS
505
        )
506
        if datetime.now(UTC) > account_premium_feature_resumed:
1!
507
            # premium feature has been resumed
UNCOV
508
            return False
×
509
        # user was flagged and the premium feature pause period is not yet over
510
        return True
1✔
511

512
    @property
1✔
513
    def metrics_enabled(self) -> bool:
1✔
514
        """
515
        Does the user allow us to record technical and interaction data?
516

517
        This is based on the Mozilla accounts opt-out option, added around 2022. A user
518
        can go to their Mozilla account profile settings, Data Collection and Use, and
519
        deselect "Help improve Mozilla Account". This setting defaults to On, and is
520
        sent as "metricsEnabled". Some older Relay accounts do not have
521
        "metricsEnabled", and we default to On.
522
        """
523
        if self.fxa:
1✔
524
            return bool(self.fxa.extra_data.get("metricsEnabled", True))
1✔
525
        return True
1✔
526

527
    @property
1✔
528
    def plan(self) -> Literal["free", "email", "phone", "bundle"]:
1✔
529
        """The user's Relay plan as a string."""
530
        if self.has_premium:
1✔
531
            if self.has_phone:
1✔
532
                return "bundle" if self.has_vpn else "phone"
1✔
533
            else:
534
                return "email"
1✔
535
        else:
536
            return "free"
1✔
537

538
    @property
1✔
539
    def plan_term(self) -> Literal[None, "unknown", "1_month", "1_year"]:
1✔
540
        """The user's Relay plan term as a string."""
541
        plan = self.plan
1✔
542
        if plan == "free":
1✔
543
            return None
1✔
544
        if plan == "phone":
1✔
545
            start_date = self.date_phone_subscription_start
1✔
546
            end_date = self.date_phone_subscription_end
1✔
547
            if start_date and end_date:
1✔
548
                span = end_date - start_date
1✔
549
                return "1_year" if span.days > 32 else "1_month"
1✔
550
        return "unknown"
1✔
551

552
    @property
1✔
553
    def metrics_premium_status(self) -> str:
1✔
554
        plan = self.plan
1✔
555
        if plan == "free":
1✔
556
            return "free"
1✔
557
        return f"{plan}_{self.plan_term}"
1✔
558

559
    @property
1✔
560
    def metrics_fxa_id(self) -> str:
1✔
561
        """Return Mozilla Accounts ID if user has metrics enabled, else empty string"""
562
        if (fxa := self.fxa) and self.metrics_enabled and isinstance(fxa.uid, str):
1✔
563
            return fxa.uid
1✔
564
        return ""
1✔
565

566

567
class RegisteredSubdomain(models.Model):
1✔
568
    subdomain_hash = models.CharField(max_length=64, db_index=True, unique=True)
1✔
569
    registered_at = models.DateTimeField(auto_now_add=True)
1✔
570

571
    def __str__(self):
1✔
UNCOV
572
        return self.subdomain_hash
×
573

574
    class Meta:
1✔
575
        # Moved from emails to privaterelay, but old table name retained. See:
576
        # privaterelay/migrations/0010_move_profile_and_registered_subdomain_models.py
577
        # emails/migrations/0062_move_profile_and_registered_subdomain_models.py
578
        db_table = "emails_registeredsubdomain"
1✔
579

580
    @classmethod
1✔
581
    def is_taken(cls, subdomain: str) -> bool:
1✔
582
        return cls.objects.filter(subdomain_hash=hash_subdomain(subdomain)).exists()
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