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

mozilla / fx-private-relay / c261d5a6-4482-49d5-8f64-a949e5295e9d

18 Apr 2024 02:57PM UTC coverage: 75.479% (-0.1%) from 75.611%
c261d5a6-4482-49d5-8f64-a949e5295e9d

Pull #4612

circleci

rafeerahman
Linter and more test fixes
Pull Request #4612: MPP3779: E2E test fixes and additions

2443 of 3406 branches covered (71.73%)

Branch coverage included in aggregate %.

6767 of 8796 relevant lines covered (76.93%)

20.09 hits per line

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

96.42
/emails/models.py
1
from __future__ import annotations
1✔
2
from collections import namedtuple
1✔
3
from datetime import datetime, timedelta, timezone
1✔
4
from hashlib import sha256
1✔
5
from typing import Iterable, Literal
1✔
6
import logging
1✔
7
import random
1✔
8
import re
1✔
9
import string
1✔
10
import uuid
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 allauth.socialaccount.models import SocialAccount
1✔
23
from rest_framework.authtoken.models import Token
1✔
24

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

33
from .apps import emails_config
1✔
34
from .utils import get_domains_from_settings, incr_if_enabled
1✔
35

36
if settings.PHONES_ENABLED:
1!
37
    from phones.models import RealPhone, RelayNumber
1✔
38

39

40
logger = logging.getLogger("events")
1✔
41
abuse_logger = logging.getLogger("abusemetrics")
1✔
42

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

73

74
# This historical function is referenced in migration
75
# 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 MPP-2972: delete date_phone_subscription_checked in favor of
93
    # date_phone_subscription_next_reset
94
    date_phone_subscription_checked = models.DateTimeField(blank=True, null=True)
1✔
95
    date_phone_subscription_start = models.DateTimeField(blank=True, null=True)
1✔
96
    date_phone_subscription_reset = models.DateTimeField(blank=True, null=True)
1✔
97
    date_phone_subscription_end = models.DateTimeField(blank=True, null=True)
1✔
98
    address_last_deleted = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
99
    last_soft_bounce = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
100
    last_hard_bounce = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
101
    last_account_flagged = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
102
    num_deleted_relay_addresses = models.PositiveIntegerField(default=0)
1✔
103
    num_deleted_domain_addresses = models.PositiveIntegerField(default=0)
1✔
104
    num_email_forwarded_in_deleted_address = models.PositiveIntegerField(default=0)
1✔
105
    num_email_blocked_in_deleted_address = models.PositiveIntegerField(default=0)
1✔
106
    num_level_one_trackers_blocked_in_deleted_address = models.PositiveIntegerField(
1✔
107
        default=0, null=True
108
    )
109
    num_email_replied_in_deleted_address = models.PositiveIntegerField(default=0)
1✔
110
    num_email_spam_in_deleted_address = models.PositiveIntegerField(default=0)
1✔
111
    subdomain = models.CharField(
1✔
112
        blank=True,
113
        null=True,
114
        unique=True,
115
        max_length=63,
116
        db_index=True,
117
        validators=[valid_available_subdomain],
118
    )
119
    # Whether we store the user's alias labels in the server
120
    server_storage = models.BooleanField(default=True)
1✔
121
    # Whether we store the caller/sender log for the user's relay number
122
    store_phone_log = models.BooleanField(default=True)
1✔
123
    # TODO: Data migration to set null to false
124
    # TODO: Schema migration to remove null=True
125
    remove_level_one_email_trackers = models.BooleanField(null=True, default=False)
1✔
126
    onboarding_state = models.PositiveIntegerField(default=0)
1✔
127
    onboarding_free_state = models.PositiveIntegerField(default=0)
1✔
128
    auto_block_spam = models.BooleanField(default=False)
1✔
129
    forwarded_first_reply = models.BooleanField(default=False)
1✔
130
    # Empty string means the profile was created through relying party flow
131
    created_by = models.CharField(blank=True, null=True, max_length=63)
1✔
132
    sent_welcome_email = models.BooleanField(default=False)
1✔
133
    last_engagement = models.DateTimeField(blank=True, null=True, db_index=True)
1✔
134

135
    def __str__(self):
1✔
136
        return "%s Profile" % self.user
1✔
137

138
    def save(
1✔
139
        self,
140
        force_insert: bool = False,
141
        force_update: bool = False,
142
        using: str | None = None,
143
        update_fields: Iterable[str] | None = None,
144
    ) -> None:
145
        # always lower-case the subdomain before saving it
146
        # TODO: change subdomain field as a custom field inheriting from
147
        # CharField to validate constraints on the field update too
148
        if self.subdomain and not self.subdomain.islower():
1✔
149
            self.subdomain = self.subdomain.lower()
1✔
150
            if update_fields is not None:
1✔
151
                update_fields = {"subdomain"}.union(update_fields)
1✔
152
        super().save(
1✔
153
            force_insert=force_insert,
154
            force_update=force_update,
155
            using=using,
156
            update_fields=update_fields,
157
        )
158
        # any time a profile is saved with server_storage False, delete the
159
        # appropriate server-stored Relay address data.
160
        if not self.server_storage:
1✔
161
            relay_addresses = RelayAddress.objects.filter(user=self.user)
1✔
162
            relay_addresses.update(description="", generated_for="", used_on="")
1✔
163
            domain_addresses = DomainAddress.objects.filter(user=self.user)
1✔
164
            domain_addresses.update(description="", used_on="")
1✔
165
        if settings.PHONES_ENABLED:
1!
166
            # any time a profile is saved with store_phone_log False, delete the
167
            # appropriate server-stored InboundContact records
168
            from phones.models import InboundContact, RelayNumber
1✔
169

170
            if not self.store_phone_log:
1✔
171
                try:
1✔
172
                    relay_number = RelayNumber.objects.get(user=self.user)
1✔
173
                    InboundContact.objects.filter(relay_number=relay_number).delete()
1✔
174
                except RelayNumber.DoesNotExist:
1✔
175
                    pass
1✔
176

177
    @property
1✔
178
    def language(self):
1✔
179
        if self.fxa and self.fxa.extra_data.get("locale"):
1✔
180
            for accept_lang, _ in parse_accept_lang_header(
1!
181
                self.fxa.extra_data.get("locale")
182
            ):
183
                try:
1✔
184
                    return get_supported_language_variant(accept_lang)
1✔
185
                except LookupError:
×
186
                    continue
×
187
        return "en"
1✔
188

189
    # This method returns whether the locale associated with the user's Mozilla account
190
    # includes a country code from a Premium country. This is less accurate than using
191
    # get_countries_info_from_request_and_mapping(), which can use a GeoIP lookup, so
192
    # prefer using that if a request context is available. In other contexts, for
193
    # example when sending an email, this method can be useful.
194
    @property
1✔
195
    def fxa_locale_in_premium_country(self) -> bool:
1✔
196
        if self.fxa and self.fxa.extra_data.get("locale"):
1✔
197
            try:
1✔
198
                country = guess_country_from_accept_lang(self.fxa.extra_data["locale"])
1✔
199
            except AcceptLanguageError:
1✔
200
                return False
1✔
201
            premium_countries = get_premium_countries()
1✔
202
            if country in premium_countries:
1✔
203
                return True
1✔
204
        return False
1✔
205

206
    @property
1✔
207
    def avatar(self) -> str | None:
1✔
208
        if fxa := self.fxa:
1!
209
            return str(fxa.extra_data.get("avatar"))
1✔
210
        return None
×
211

212
    @property
1✔
213
    def relay_addresses(self):
1✔
214
        return RelayAddress.objects.filter(user=self.user)
1✔
215

216
    @property
1✔
217
    def domain_addresses(self):
1✔
218
        return DomainAddress.objects.filter(user=self.user)
1✔
219

220
    @property
1✔
221
    def total_masks(self) -> int:
1✔
222
        ra_count: int = self.relay_addresses.count()
1✔
223
        da_count: int = self.domain_addresses.count()
1✔
224
        return ra_count + da_count
1✔
225

226
    @property
1✔
227
    def at_mask_limit(self) -> bool:
1✔
228
        if self.has_premium:
1✔
229
            return False
1✔
230
        ra_count: int = self.relay_addresses.count()
1✔
231
        return ra_count >= settings.MAX_NUM_FREE_ALIASES
1✔
232

233
    def check_bounce_pause(self):
1✔
234
        if self.last_hard_bounce:
1✔
235
            last_hard_bounce_allowed = datetime.now(timezone.utc) - timedelta(
1✔
236
                days=settings.HARD_BOUNCE_ALLOWED_DAYS
237
            )
238
            if self.last_hard_bounce > last_hard_bounce_allowed:
1✔
239
                return BounceStatus(True, "hard")
1✔
240
            self.last_hard_bounce = None
1✔
241
            self.save()
1✔
242
        if self.last_soft_bounce:
1✔
243
            last_soft_bounce_allowed = datetime.now(timezone.utc) - timedelta(
1✔
244
                days=settings.SOFT_BOUNCE_ALLOWED_DAYS
245
            )
246
            if self.last_soft_bounce > last_soft_bounce_allowed:
1✔
247
                return BounceStatus(True, "soft")
1✔
248
            self.last_soft_bounce = None
1✔
249
            self.save()
1✔
250
        return BounceStatus(False, "")
1✔
251

252
    @property
1✔
253
    def bounce_status(self):
1✔
254
        return self.check_bounce_pause()
1✔
255

256
    @property
1✔
257
    def next_email_try(self):
1✔
258
        bounce_pause, bounce_type = self.check_bounce_pause()
1✔
259

260
        if not bounce_pause:
1✔
261
            return datetime.now(timezone.utc)
1✔
262

263
        if bounce_type == "soft":
1✔
264
            assert self.last_soft_bounce
1✔
265
            return self.last_soft_bounce + timedelta(
1✔
266
                days=settings.SOFT_BOUNCE_ALLOWED_DAYS
267
            )
268

269
        assert bounce_type == "hard"
1✔
270
        assert self.last_hard_bounce
1✔
271
        return self.last_hard_bounce + timedelta(days=settings.HARD_BOUNCE_ALLOWED_DAYS)
1✔
272

273
    @property
1✔
274
    def last_bounce_date(self):
1✔
275
        if self.last_hard_bounce:
1✔
276
            return self.last_hard_bounce
1✔
277
        if self.last_soft_bounce:
1✔
278
            return self.last_soft_bounce
1✔
279
        return None
1✔
280

281
    @property
1✔
282
    def at_max_free_aliases(self) -> bool:
1✔
283
        relay_addresses_count: int = self.relay_addresses.count()
1✔
284
        return relay_addresses_count >= settings.MAX_NUM_FREE_ALIASES
1✔
285

286
    @property
1✔
287
    def fxa(self) -> SocialAccount | None:
1✔
288
        # Note: we are NOT using .filter() here because it invalidates
289
        # any profile instances that were queried with prefetch_related, which
290
        # we use in at least the profile view to minimize queries
291
        assert hasattr(self.user, "socialaccount_set")
1✔
292
        for sa in self.user.socialaccount_set.all():
1✔
293
            if sa.provider == "fxa":
1!
294
                return sa
1✔
295
        return None
1✔
296

297
    @property
1✔
298
    def display_name(self) -> str | None:
1✔
299
        # if display name is not set on FxA the
300
        # displayName key will not exist on the extra_data
301
        if fxa := self.fxa:
1!
302
            name = fxa.extra_data.get("displayName")
1✔
303
            return name if name is None else str(name)
1✔
304
        return None
×
305

306
    @property
1✔
307
    def custom_domain(self) -> str:
1✔
308
        assert self.subdomain
×
309
        return f"@{self.subdomain}.{settings.MOZMAIL_DOMAIN}"
×
310

311
    @property
1✔
312
    def has_premium(self) -> bool:
1✔
313
        # FIXME: as we don't have all the tiers defined we are over-defining
314
        # this to mark the user as a premium user as well
315
        if not self.fxa:
1✔
316
            return False
1✔
317
        for premium_domain in PREMIUM_DOMAINS:
1✔
318
            if self.user.email.endswith(f"@{premium_domain}"):
1!
319
                return True
×
320
        user_subscriptions = self.fxa.extra_data.get("subscriptions", [])
1✔
321
        for sub in settings.SUBSCRIPTIONS_WITH_UNLIMITED:
1✔
322
            if sub in user_subscriptions:
1✔
323
                return True
1✔
324
        return False
1✔
325

326
    @property
1✔
327
    def has_phone(self) -> bool:
1✔
328
        if not self.fxa:
1✔
329
            return False
1✔
330
        if settings.RELAY_CHANNEL != "prod" and not settings.IN_PYTEST:
1!
331
            if not flag_is_active_in_task("phones", self.user):
×
332
                return False
×
333
        if flag_is_active_in_task("free_phones", self.user):
1!
334
            return True
×
335
        user_subscriptions = self.fxa.extra_data.get("subscriptions", [])
1✔
336
        for sub in settings.SUBSCRIPTIONS_WITH_PHONE:
1✔
337
            if sub in user_subscriptions:
1✔
338
                return True
1✔
339
        return False
1✔
340

341
    @property
1✔
342
    def has_vpn(self):
1✔
343
        if not self.fxa:
1!
344
            return False
×
345
        user_subscriptions = self.fxa.extra_data.get("subscriptions", [])
1✔
346
        for sub in settings.SUBSCRIPTIONS_WITH_VPN:
1✔
347
            if sub in user_subscriptions:
1✔
348
                return True
1✔
349
        return False
1✔
350

351
    @property
1✔
352
    def emails_forwarded(self):
1✔
353
        return (
1✔
354
            sum(ra.num_forwarded for ra in self.relay_addresses)
355
            + sum(da.num_forwarded for da in self.domain_addresses)
356
            + self.num_email_forwarded_in_deleted_address
357
        )
358

359
    @property
1✔
360
    def emails_blocked(self):
1✔
361
        return (
1✔
362
            sum(ra.num_blocked for ra in self.relay_addresses)
363
            + sum(da.num_blocked for da in self.domain_addresses)
364
            + self.num_email_blocked_in_deleted_address
365
        )
366

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

381
    @property
1✔
382
    def level_one_trackers_blocked(self):
1✔
383
        return (
1✔
384
            sum(ra.num_level_one_trackers_blocked or 0 for ra in self.relay_addresses)
385
            + sum(
386
                da.num_level_one_trackers_blocked or 0 for da in self.domain_addresses
387
            )
388
            + (self.num_level_one_trackers_blocked_in_deleted_address or 0)
389
        )
390

391
    @property
1✔
392
    def joined_before_premium_release(self):
1✔
393
        date_created = self.user.date_joined
1✔
394
        return date_created < datetime.fromisoformat("2021-10-22 17:00:00+00:00")
1✔
395

396
    @property
1✔
397
    def date_phone_registered(self) -> datetime | None:
1✔
398
        if not settings.PHONES_ENABLED:
1!
399
            return None
×
400

401
        try:
1✔
402
            real_phone = RealPhone.objects.get(user=self.user)
1✔
403
            relay_number = RelayNumber.objects.get(user=self.user)
1✔
404
        except RealPhone.DoesNotExist:
1✔
405
            return None
1✔
406
        except RelayNumber.DoesNotExist:
1✔
407
            return real_phone.verified_date
1✔
408
        return relay_number.created_at or real_phone.verified_date
1✔
409

410
    def add_subdomain(self, subdomain):
1✔
411
        # Handles if the subdomain is "" or None
412
        if not subdomain:
1✔
413
            raise CannotMakeSubdomainException(
1✔
414
                "error-subdomain-cannot-be-empty-or-null"
415
            )
416

417
        # subdomain must be all lowercase
418
        subdomain = subdomain.lower()
1✔
419

420
        if not self.has_premium:
1✔
421
            raise CannotMakeSubdomainException("error-premium-set-subdomain")
1✔
422
        if self.subdomain is not None:
1✔
423
            raise CannotMakeSubdomainException("error-premium-cannot-change-subdomain")
1✔
424
        self.subdomain = subdomain
1✔
425
        # The validator defined in the subdomain field does not get run in full_clean()
426
        # when self.subdomain is "" or None, so we need to run the validator again to
427
        # catch these cases.
428
        valid_available_subdomain(subdomain)
1✔
429
        self.full_clean()
1✔
430
        self.save()
1✔
431

432
        RegisteredSubdomain.objects.create(subdomain_hash=hash_subdomain(subdomain))
1✔
433
        return subdomain
1✔
434

435
    def update_abuse_metric(
1✔
436
        self,
437
        address_created=False,
438
        replied=False,
439
        email_forwarded=False,
440
        forwarded_email_size=0,
441
    ) -> datetime | None:
442
        # TODO MPP-3720: This should be wrapped in atomic or select_for_update to ensure
443
        # race conditions are properly handled.
444

445
        # look for abuse metrics created on the same UTC date, regardless of time.
446
        midnight_utc_today = datetime.combine(
1✔
447
            datetime.now(timezone.utc).date(), datetime.min.time()
448
        ).astimezone(timezone.utc)
449
        midnight_utc_tomorow = midnight_utc_today + timedelta(days=1)
1✔
450
        abuse_metric = self.user.abusemetrics_set.filter(
1✔
451
            first_recorded__gte=midnight_utc_today,
452
            first_recorded__lt=midnight_utc_tomorow,
453
        ).first()
454
        if not abuse_metric:
1✔
455
            abuse_metric = AbuseMetrics.objects.create(user=self.user)
1✔
456
            AbuseMetrics.objects.filter(first_recorded__lt=midnight_utc_today).delete()
1✔
457

458
        # increment the abuse metric
459
        if address_created:
1✔
460
            abuse_metric.num_address_created_per_day += 1
1✔
461
        if replied:
1✔
462
            abuse_metric.num_replies_per_day += 1
1✔
463
        if email_forwarded:
1✔
464
            abuse_metric.num_email_forwarded_per_day += 1
1✔
465
        if forwarded_email_size > 0:
1✔
466
            abuse_metric.forwarded_email_size_per_day += forwarded_email_size
1✔
467
        abuse_metric.last_recorded = datetime.now(timezone.utc)
1✔
468
        abuse_metric.save()
1✔
469

470
        # check user should be flagged for abuse
471
        hit_max_create = False
1✔
472
        hit_max_replies = False
1✔
473
        hit_max_forwarded = False
1✔
474
        hit_max_forwarded_email_size = False
1✔
475

476
        hit_max_create = (
1✔
477
            abuse_metric.num_address_created_per_day
478
            >= settings.MAX_ADDRESS_CREATION_PER_DAY
479
        )
480
        hit_max_replies = (
1✔
481
            abuse_metric.num_replies_per_day >= settings.MAX_REPLIES_PER_DAY
482
        )
483
        hit_max_forwarded = (
1✔
484
            abuse_metric.num_email_forwarded_per_day >= settings.MAX_FORWARDED_PER_DAY
485
        )
486
        hit_max_forwarded_email_size = (
1✔
487
            abuse_metric.forwarded_email_size_per_day
488
            >= settings.MAX_FORWARDED_EMAIL_SIZE_PER_DAY
489
        )
490
        if (
1✔
491
            hit_max_create
492
            or hit_max_replies
493
            or hit_max_forwarded
494
            or hit_max_forwarded_email_size
495
        ):
496
            self.last_account_flagged = datetime.now(timezone.utc)
1✔
497
            self.save()
1✔
498
            data = {
1✔
499
                "uid": self.fxa.uid if self.fxa else None,
500
                "flagged": self.last_account_flagged.timestamp(),
501
                "replies": abuse_metric.num_replies_per_day,
502
                "addresses": abuse_metric.num_address_created_per_day,
503
                "forwarded": abuse_metric.num_email_forwarded_per_day,
504
                "forwarded_size_in_bytes": abuse_metric.forwarded_email_size_per_day,
505
            }
506
            # log for further secops review
507
            abuse_logger.info("Abuse flagged", extra=data)
1✔
508
        return self.last_account_flagged
1✔
509

510
    @property
1✔
511
    def is_flagged(self):
1✔
512
        if not self.last_account_flagged:
1✔
513
            return False
1✔
514
        account_premium_feature_resumed = self.last_account_flagged + timedelta(
1✔
515
            days=settings.PREMIUM_FEATURE_PAUSED_DAYS
516
        )
517
        if datetime.now(timezone.utc) > account_premium_feature_resumed:
1!
518
            # premium feature has been resumed
519
            return False
×
520
        # user was flagged and the premium feature pause period is not yet over
521
        return True
1✔
522

523
    @property
1✔
524
    def metrics_enabled(self) -> bool:
1✔
525
        """
526
        Does the user allow us to record technical and interaction data?
527

528
        This is based on the Mozilla accounts opt-out option, added around 2022. A user
529
        can go to their Mozilla account profile settings, Data Collection and Use, and
530
        deselect "Help improve Mozilla Account". This setting defaults to On, and is
531
        sent as "metricsEnabled". Some older Relay accounts do not have
532
        "metricsEnabled", and we default to On.
533
        """
534
        if self.fxa:
1✔
535
            return bool(self.fxa.extra_data.get("metricsEnabled", True))
1✔
536
        return True
1✔
537

538
    @property
1✔
539
    def plan(self) -> Literal["free", "email", "phone", "bundle"]:
1✔
540
        """The user's Relay plan as a string."""
541
        if self.has_premium:
1✔
542
            if self.has_phone:
1✔
543
                return "bundle" if self.has_vpn else "phone"
1✔
544
            else:
545
                return "email"
1✔
546
        else:
547
            return "free"
1✔
548

549
    @property
1✔
550
    def plan_term(self) -> Literal[None, "unknown", "1_month", "1_year"]:
1✔
551
        """The user's Relay plan term as a string."""
552
        plan = self.plan
1✔
553
        if plan == "free":
1✔
554
            return None
1✔
555
        if plan == "phone":
1✔
556
            start_date = self.date_phone_subscription_start
1✔
557
            end_date = self.date_phone_subscription_end
1✔
558
            if start_date and end_date:
1✔
559
                span = end_date - start_date
1✔
560
                return "1_year" if span.days > 32 else "1_month"
1✔
561
        return "unknown"
1✔
562

563
    @property
1✔
564
    def metrics_premium_status(self) -> str:
1✔
565
        plan = self.plan
1✔
566
        if plan == "free":
1✔
567
            return "free"
1✔
568
        return f"{plan}_{self.plan_term}"
1✔
569

570

571
@receiver(models.signals.post_save, sender=Profile)
1✔
572
def copy_auth_token(sender, instance=None, created=False, **kwargs):
1✔
573
    if created:
1✔
574
        # baker triggers created during tests
575
        # so first check the user doesn't already have a Token
576
        try:
1✔
577
            Token.objects.get(user=instance.user)
1✔
578
            return
1✔
579
        except Token.DoesNotExist:
1✔
580
            Token.objects.create(user=instance.user, key=instance.api_token)
1✔
581

582

583
def address_hash(address, subdomain=None, domain=None):
1✔
584
    if not domain:
1✔
585
        domain = get_domains_from_settings()["MOZMAIL_DOMAIN"]
1✔
586
    if subdomain:
1✔
587
        return sha256(f"{address}@{subdomain}.{domain}".encode("utf-8")).hexdigest()
1✔
588
    if domain == settings.RELAY_FIREFOX_DOMAIN:
1✔
589
        return sha256(f"{address}".encode("utf-8")).hexdigest()
1✔
590
    return sha256(f"{address}@{domain}".encode("utf-8")).hexdigest()
1✔
591

592

593
def address_default():
1✔
594
    return "".join(random.choices(string.ascii_lowercase + string.digits, k=9))
1✔
595

596

597
def has_bad_words(value) -> bool:
1✔
598
    for badword in emails_config().badwords:
1✔
599
        badword = badword.strip()
1✔
600
        if len(badword) <= 4 and badword == value:
1✔
601
            return True
1✔
602
        if len(badword) > 4 and badword in value:
1✔
603
            return True
1✔
604
    return False
1✔
605

606

607
def is_blocklisted(value: str) -> bool:
1✔
608
    return any(blockedword == value for blockedword in emails_config().blocklist)
1✔
609

610

611
def get_domain_numerical(domain_address):
1✔
612
    # get domain name from the address
613
    domains = get_domains_from_settings()
1✔
614
    domains_keys = list(domains.keys())
1✔
615
    domains_values = list(domains.values())
1✔
616
    domain_name = domains_keys[domains_values.index(domain_address)]
1✔
617
    # get domain numerical value from domain name
618
    choices = dict(DOMAIN_CHOICES)
1✔
619
    choices_keys = list(choices.keys())
1✔
620
    choices_values = list(choices.values())
1✔
621
    return choices_keys[choices_values.index(domain_name)]
1✔
622

623

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

627

628
class RegisteredSubdomain(models.Model):
1✔
629
    subdomain_hash = models.CharField(max_length=64, db_index=True, unique=True)
1✔
630
    registered_at = models.DateTimeField(auto_now_add=True)
1✔
631

632
    def __str__(self):
1✔
633
        return self.subdomain_hash
×
634

635

636
class CannotMakeSubdomainException(BadRequest):
1✔
637
    """Exception raised by Profile due to error on subdomain creation.
638

639
    Attributes:
640
        message -- optional explanation of the error
641
    """
642

643
    def __init__(self, message=None):
1✔
644
        self.message = message
1✔
645

646

647
class CannotMakeAddressException(RelayAPIException):
1✔
648
    """Base exception for RelayAddress or DomainAddress creation failure."""
649

650

651
class AccountIsPausedException(CannotMakeAddressException):
1✔
652
    default_code = "account_is_paused"
1✔
653
    default_detail = "Your account is on pause."
1✔
654
    status_code = 403
1✔
655

656

657
class RelayAddrFreeTierLimitException(CannotMakeAddressException):
1✔
658
    default_code = "free_tier_limit"
1✔
659
    default_detail_template = (
1✔
660
        "You’ve used all {free_tier_limit} email masks included with your free account."
661
        " You can reuse an existing mask, but using a unique mask for each account is"
662
        " the most secure option."
663
    )
664
    status_code = 403
1✔
665

666
    def __init__(self, free_tier_limit: int | None = None, *args, **kwargs):
1✔
667
        self.free_tier_limit = free_tier_limit or settings.MAX_NUM_FREE_ALIASES
1✔
668
        super().__init__(*args, **kwargs)
1✔
669

670
    def error_context(self) -> ErrorContextType:
1✔
671
        return {"free_tier_limit": self.free_tier_limit}
1✔
672

673

674
class DomainAddrFreeTierException(CannotMakeAddressException):
1✔
675
    default_code = "free_tier_no_subdomain_masks"
1✔
676
    default_detail = (
1✔
677
        "Your free account does not include custom subdomains for masks."
678
        " To create custom masks, upgrade to Relay Premium."
679
    )
680
    status_code = 403
1✔
681

682

683
class DomainAddrNeedSubdomainException(CannotMakeAddressException):
1✔
684
    default_code = "need_subdomain"
1✔
685
    default_detail = "Please select a subdomain before creating a custom email address."
1✔
686
    status_code = 400
1✔
687

688

689
class DomainAddrUpdateException(CannotMakeAddressException):
1✔
690
    """Exception raised when attempting to edit an existing domain address field."""
691

692
    default_code = "address_not_editable"
1✔
693
    default_detail = "You cannot edit an existing domain address field."
1✔
694
    status_code = 400
1✔
695

696

697
class DomainAddrUnavailableException(CannotMakeAddressException):
1✔
698
    default_code = "address_unavailable"
1✔
699
    default_detail_template = (
1✔
700
        "“{unavailable_address}” could not be created."
701
        " Please try again with a different mask name."
702
    )
703
    status_code = 400
1✔
704

705
    def __init__(self, unavailable_address: str, *args, **kwargs):
1✔
706
        self.unavailable_address = unavailable_address
1✔
707
        super().__init__(*args, **kwargs)
1✔
708

709
    def error_context(self) -> ErrorContextType:
1✔
710
        return {"unavailable_address": self.unavailable_address}
1✔
711

712

713
class DomainAddrDuplicateException(CannotMakeAddressException):
1✔
714
    default_code = "duplicate_address"
1✔
715
    default_detail_template = (
1✔
716
        "“{duplicate_address}” already exists."
717
        " Please try again with a different mask name."
718
    )
719
    status_code = 409
1✔
720

721
    def __init__(self, duplicate_address: str, *args, **kwargs):
1✔
722
        self.duplicate_address = duplicate_address
1✔
723
        super().__init__(*args, **kwargs)
1✔
724

725
    def error_context(self) -> ErrorContextType:
1✔
726
        return {"duplicate_address": self.duplicate_address}
1✔
727

728

729
class RelayAddress(models.Model):
1✔
730
    user = models.ForeignKey(User, on_delete=models.CASCADE)
1✔
731
    address = models.CharField(max_length=64, default=address_default, unique=True)
1✔
732
    domain = models.PositiveSmallIntegerField(
1✔
733
        choices=DOMAIN_CHOICES, default=default_domain_numerical
734
    )
735
    enabled = models.BooleanField(default=True)
1✔
736
    description = models.CharField(max_length=64, blank=True)
1✔
737
    created_at = models.DateTimeField(auto_now_add=True, db_index=True)
1✔
738
    last_modified_at = models.DateTimeField(auto_now=True, db_index=True)
1✔
739
    last_used_at = models.DateTimeField(blank=True, null=True)
1✔
740
    num_forwarded = models.PositiveIntegerField(default=0)
1✔
741
    num_blocked = models.PositiveIntegerField(default=0)
1✔
742
    num_level_one_trackers_blocked = models.PositiveIntegerField(default=0, null=True)
1✔
743
    num_replied = models.PositiveIntegerField(default=0)
1✔
744
    num_spam = models.PositiveIntegerField(default=0)
1✔
745
    generated_for = models.CharField(max_length=255, blank=True)
1✔
746
    block_list_emails = models.BooleanField(default=False)
1✔
747
    used_on = models.TextField(default=None, blank=True, null=True)
1✔
748

749
    class Meta:
1✔
750
        indexes = [
1✔
751
            # Find when a user first used the add-on
752
            models.Index(
753
                name="idx_ra_created_by_addon",
754
                fields=["user"],
755
                condition=~models.Q(generated_for__exact=""),
756
                include=["created_at"],
757
            ),
758
        ]
759

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

763
    def delete(self, *args, **kwargs):
1✔
764
        # TODO: create hard bounce receipt rule in AWS for the address
765
        deleted_address = DeletedAddress.objects.create(
1✔
766
            address_hash=address_hash(self.address, domain=self.domain_value),
767
            num_forwarded=self.num_forwarded,
768
            num_blocked=self.num_blocked,
769
            num_replied=self.num_replied,
770
            num_spam=self.num_spam,
771
        )
772
        deleted_address.save()
1✔
773
        profile = Profile.objects.get(user=self.user)
1✔
774
        profile.address_last_deleted = datetime.now(timezone.utc)
1✔
775
        profile.num_address_deleted += 1
1✔
776
        profile.num_email_forwarded_in_deleted_address += self.num_forwarded
1✔
777
        profile.num_email_blocked_in_deleted_address += self.num_blocked
1✔
778
        profile.num_level_one_trackers_blocked_in_deleted_address = (
1✔
779
            profile.num_level_one_trackers_blocked_in_deleted_address or 0
780
        ) + (self.num_level_one_trackers_blocked or 0)
781
        profile.num_email_replied_in_deleted_address += self.num_replied
1✔
782
        profile.num_email_spam_in_deleted_address += self.num_spam
1✔
783
        profile.num_deleted_relay_addresses += 1
1✔
784
        profile.last_engagement = datetime.now(timezone.utc)
1✔
785
        profile.save()
1✔
786
        return super(RelayAddress, self).delete(*args, **kwargs)
1✔
787

788
    def save(
1✔
789
        self,
790
        force_insert: bool = False,
791
        force_update: bool = False,
792
        using: str | None = None,
793
        update_fields: Iterable[str] | None = None,
794
    ) -> None:
795
        if self._state.adding:
1✔
796
            with transaction.atomic():
1✔
797
                locked_profile = Profile.objects.select_for_update().get(user=self.user)
1✔
798
                check_user_can_make_another_address(locked_profile)
1✔
799
                while True:
1✔
800
                    address_is_allowed = not is_blocklisted(self.address)
1✔
801
                    address_is_valid = valid_address(self.address, self.domain_value)
1✔
802
                    if address_is_valid and address_is_allowed:
1✔
803
                        break
1✔
804
                    self.address = address_default()
1✔
805
                locked_profile.update_abuse_metric(address_created=True)
1✔
806
                locked_profile.last_engagement = datetime.now(timezone.utc)
1✔
807
                locked_profile.save()
1✔
808
        if (not self.user.profile.server_storage) and any(
1✔
809
            (self.description, self.generated_for, self.used_on)
810
        ):
811
            self.description = ""
1✔
812
            self.generated_for = ""
1✔
813
            self.used_on = ""
1✔
814
            if update_fields is not None:
1✔
815
                update_fields = {"description", "generated_for", "used_on"}.union(
1✔
816
                    update_fields
817
                )
818
        if not self.user.profile.has_premium and self.block_list_emails:
1✔
819
            self.block_list_emails = False
1✔
820
            if update_fields is not None:
1✔
821
                update_fields = {"block_list_emails"}.union(update_fields)
1✔
822
        super().save(
1✔
823
            force_insert=force_insert,
824
            force_update=force_update,
825
            using=using,
826
            update_fields=update_fields,
827
        )
828

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

833
    @property
1✔
834
    def full_address(self):
1✔
835
        return "%s@%s" % (self.address, self.domain_value)
1✔
836

837
    @property
1✔
838
    def metrics_id(self) -> str:
1✔
839
        assert self.id
1✔
840
        # Prefix with 'R' for RelayAddress, since there may be a DomainAddress with the
841
        # same row ID
842
        return f"R{self.id}"
1✔
843

844

845
def check_user_can_make_another_address(profile: Profile) -> None:
1✔
846
    if profile.is_flagged:
1✔
847
        raise AccountIsPausedException()
1✔
848
    # MPP-3021: return early for premium users to avoid at_max_free_aliases DB query
849
    if profile.has_premium:
1✔
850
        return
1✔
851
    if profile.at_max_free_aliases:
1✔
852
        raise RelayAddrFreeTierLimitException()
1✔
853

854

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

861

862
def valid_address(address: str, domain: str, subdomain: str | None = None) -> bool:
1✔
863
    address_pattern_valid = valid_address_pattern(address)
1✔
864
    address_contains_badword = has_bad_words(address)
1✔
865
    address_already_deleted = 0
1✔
866
    if not subdomain or flag_is_active_in_task(
1✔
867
        "custom_domain_management_redesign", None
868
    ):
869
        address_already_deleted = DeletedAddress.objects.filter(
1✔
870
            address_hash=address_hash(address, domain=domain, subdomain=subdomain)
871
        ).count()
872
    if (
1✔
873
        address_already_deleted > 0
874
        or address_contains_badword
875
        or not address_pattern_valid
876
    ):
877
        return False
1✔
878
    return True
1✔
879

880

881
class DeletedAddress(models.Model):
1✔
882
    address_hash = models.CharField(max_length=64, db_index=True)
1✔
883
    num_forwarded = models.PositiveIntegerField(default=0)
1✔
884
    num_blocked = models.PositiveIntegerField(default=0)
1✔
885
    num_replied = models.PositiveIntegerField(default=0)
1✔
886
    num_spam = models.PositiveIntegerField(default=0)
1✔
887

888
    def __str__(self):
1✔
889
        return self.address_hash
×
890

891

892
def check_user_can_make_domain_address(user_profile: Profile) -> None:
1✔
893
    if not user_profile.has_premium:
1✔
894
        raise DomainAddrFreeTierException()
1✔
895

896
    if not user_profile.subdomain:
1✔
897
        raise DomainAddrNeedSubdomainException()
1✔
898

899
    if user_profile.is_flagged:
1✔
900
        raise AccountIsPausedException()
1✔
901

902

903
class DomainAddress(models.Model):
1✔
904
    user = models.ForeignKey(User, on_delete=models.CASCADE)
1✔
905
    address = models.CharField(
1✔
906
        max_length=64, validators=[MinLengthValidator(limit_value=1)]
907
    )
908
    enabled = models.BooleanField(default=True)
1✔
909
    description = models.CharField(max_length=64, blank=True)
1✔
910
    domain = models.PositiveSmallIntegerField(choices=DOMAIN_CHOICES, default=2)
1✔
911
    created_at = models.DateTimeField(auto_now_add=True, db_index=True)
1✔
912
    first_emailed_at = models.DateTimeField(null=True, db_index=True)
1✔
913
    last_modified_at = models.DateTimeField(auto_now=True, db_index=True)
1✔
914
    last_used_at = models.DateTimeField(blank=True, null=True)
1✔
915
    num_forwarded = models.PositiveIntegerField(default=0)
1✔
916
    num_blocked = models.PositiveIntegerField(default=0)
1✔
917
    num_level_one_trackers_blocked = models.PositiveIntegerField(default=0, null=True)
1✔
918
    num_replied = models.PositiveIntegerField(default=0)
1✔
919
    num_spam = models.PositiveIntegerField(default=0)
1✔
920
    block_list_emails = models.BooleanField(default=False)
1✔
921
    used_on = models.TextField(default=None, blank=True, null=True)
1✔
922

923
    class Meta:
1✔
924
        unique_together = ["user", "address"]
1✔
925

926
    def __str__(self):
1✔
927
        return self.address
×
928

929
    def save(
1✔
930
        self,
931
        force_insert: bool = False,
932
        force_update: bool = False,
933
        using: str | None = None,
934
        update_fields: Iterable[str] | None = None,
935
    ) -> None:
936
        user_profile = self.user.profile
1✔
937
        if self._state.adding:
1✔
938
            check_user_can_make_domain_address(user_profile)
1✔
939
            domain_address_valid = valid_address(
1✔
940
                self.address, self.domain_value, user_profile.subdomain
941
            )
942
            if not domain_address_valid:
1✔
943
                if self.first_emailed_at:
1!
944
                    incr_if_enabled("domainaddress.create_via_email_fail")
×
945
                raise DomainAddrUnavailableException(unavailable_address=self.address)
1✔
946

947
            if DomainAddress.objects.filter(
1✔
948
                user=self.user, address=self.address
949
            ).exists():
950
                raise DomainAddrDuplicateException(duplicate_address=self.address)
1✔
951

952
            user_profile.update_abuse_metric(address_created=True)
1✔
953
            user_profile.last_engagement = datetime.now(timezone.utc)
1✔
954
            user_profile.save(update_fields=["last_engagement"])
1✔
955
            incr_if_enabled("domainaddress.create")
1✔
956
            if self.first_emailed_at:
1✔
957
                incr_if_enabled("domainaddress.create_via_email")
1✔
958
        else:
959
            # The model is in an update state, do not allow 'address' field updates
960
            existing_instance = DomainAddress.objects.get(id=self.id)
1✔
961
            if existing_instance.address != self.address:
1✔
962
                raise DomainAddrUpdateException()
1✔
963

964
        if not user_profile.has_premium and self.block_list_emails:
1✔
965
            self.block_list_emails = False
1✔
966
            if update_fields:
1✔
967
                update_fields = {"block_list_emails"}.union(update_fields)
1✔
968
        if (not user_profile.server_storage) and (self.description or self.used_on):
1✔
969
            self.description = ""
1✔
970
            self.used_on = ""
1✔
971
            if update_fields:
1✔
972
                update_fields = {"description", "used_on"}.union(update_fields)
1✔
973
        super().save(
1✔
974
            force_insert=force_insert,
975
            force_update=force_update,
976
            using=using,
977
            update_fields=update_fields,
978
        )
979

980
    @property
1✔
981
    def user_profile(self):
1✔
982
        return Profile.objects.get(user=self.user)
1✔
983

984
    @staticmethod
1✔
985
    def make_domain_address(
1✔
986
        user_profile: Profile, address: str | None = None, made_via_email: bool = False
987
    ) -> "DomainAddress":
988
        check_user_can_make_domain_address(user_profile)
1✔
989

990
        if not address:
1✔
991
            # FIXME: if the alias is randomly generated and has bad words
992
            # we should retry like make_relay_address does
993
            # not fixing this now because not sure randomly generated
994
            # DomainAlias will be a feature
995
            address = address_default()
1✔
996
            # Only check for bad words if randomly generated
997
        assert isinstance(address, str)
1✔
998

999
        first_emailed_at = datetime.now(timezone.utc) if made_via_email else None
1✔
1000
        domain_address = DomainAddress.objects.create(
1✔
1001
            user=user_profile.user, address=address, first_emailed_at=first_emailed_at
1002
        )
1003
        return domain_address
1✔
1004

1005
    def delete(self, *args, **kwargs):
1✔
1006
        # TODO: create hard bounce receipt rule in AWS for the address
1007
        deleted_address = DeletedAddress.objects.create(
1✔
1008
            address_hash=address_hash(
1009
                self.address, self.user_profile.subdomain, self.domain_value
1010
            ),
1011
            num_forwarded=self.num_forwarded,
1012
            num_blocked=self.num_blocked,
1013
            num_replied=self.num_replied,
1014
            num_spam=self.num_spam,
1015
        )
1016
        deleted_address.save()
1✔
1017
        # self.user_profile is a property and should not be used to
1018
        # update values on the user's profile
1019
        profile = Profile.objects.get(user=self.user)
1✔
1020
        profile.address_last_deleted = datetime.now(timezone.utc)
1✔
1021
        profile.num_address_deleted += 1
1✔
1022
        profile.num_email_forwarded_in_deleted_address += self.num_forwarded
1✔
1023
        profile.num_email_blocked_in_deleted_address += self.num_blocked
1✔
1024
        profile.num_level_one_trackers_blocked_in_deleted_address = (
1✔
1025
            profile.num_level_one_trackers_blocked_in_deleted_address or 0
1026
        ) + (self.num_level_one_trackers_blocked or 0)
1027
        profile.num_email_replied_in_deleted_address += self.num_replied
1✔
1028
        profile.num_email_spam_in_deleted_address += self.num_spam
1✔
1029
        profile.num_deleted_domain_addresses += 1
1✔
1030
        profile.last_engagement = datetime.now(timezone.utc)
1✔
1031
        profile.save()
1✔
1032
        return super(DomainAddress, self).delete(*args, **kwargs)
1✔
1033

1034
    @property
1✔
1035
    def domain_value(self):
1✔
1036
        return get_domains_from_settings().get(self.get_domain_display())
1✔
1037

1038
    @property
1✔
1039
    def full_address(self):
1✔
1040
        return "%s@%s.%s" % (
1✔
1041
            self.address,
1042
            self.user_profile.subdomain,
1043
            self.domain_value,
1044
        )
1045

1046
    @property
1✔
1047
    def metrics_id(self) -> str:
1✔
1048
        assert self.id
1✔
1049
        # Prefix with 'D' for DomainAddress, since there may be a RelayAddress with the
1050
        # same row ID
1051
        return f"D{self.id}"
1✔
1052

1053

1054
class Reply(models.Model):
1✔
1055
    relay_address = models.ForeignKey(
1✔
1056
        RelayAddress, on_delete=models.CASCADE, blank=True, null=True
1057
    )
1058
    domain_address = models.ForeignKey(
1✔
1059
        DomainAddress, on_delete=models.CASCADE, blank=True, null=True
1060
    )
1061
    lookup = models.CharField(max_length=255, blank=False, db_index=True)
1✔
1062
    encrypted_metadata = models.TextField(blank=False)
1✔
1063
    created_at = models.DateField(auto_now_add=True, null=False, db_index=True)
1✔
1064

1065
    @property
1✔
1066
    def address(self):
1✔
1067
        return self.relay_address or self.domain_address
1✔
1068

1069
    @property
1✔
1070
    def profile(self):
1✔
1071
        return self.address.user.profile
1✔
1072

1073
    @property
1✔
1074
    def owner_has_premium(self):
1✔
1075
        return self.profile.has_premium
1✔
1076

1077
    def increment_num_replied(self):
1✔
1078
        address = self.relay_address or self.domain_address
1✔
1079
        assert address
1✔
1080
        address.num_replied += 1
1✔
1081
        address.last_used_at = datetime.now(timezone.utc)
1✔
1082
        address.save(update_fields=["num_replied", "last_used_at"])
1✔
1083
        return address.num_replied
1✔
1084

1085

1086
class AbuseMetrics(models.Model):
1✔
1087
    user = models.ForeignKey(User, on_delete=models.CASCADE)
1✔
1088
    first_recorded = models.DateTimeField(auto_now_add=True, db_index=True)
1✔
1089
    last_recorded = models.DateTimeField(auto_now_add=True, db_index=True)
1✔
1090
    num_address_created_per_day = models.PositiveSmallIntegerField(default=0)
1✔
1091
    num_replies_per_day = models.PositiveSmallIntegerField(default=0)
1✔
1092
    # Values from 0 to 32767 are safe in all databases supported by Django.
1093
    num_email_forwarded_per_day = models.PositiveSmallIntegerField(default=0)
1✔
1094
    # Values from 0 to 9.2 exabytes are safe in all databases supported by Django.
1095
    forwarded_email_size_per_day = models.PositiveBigIntegerField(default=0)
1✔
1096

1097
    class Meta:
1✔
1098
        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