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

mozilla / fx-private-relay / 4276dcd9-8c30-4786-8a41-f9c1cdae7f05

05 Mar 2024 07:15PM CUT coverage: 74.734% (+0.6%) from 74.139%
4276dcd9-8c30-4786-8a41-f9c1cdae7f05

Pull #4452

circleci

jwhitlock
Pass user to create_expected_glean_event

Pass the related user to the test helper create_expected_glean_event, so
that the user-specific values such as fxa_id and date_joined_relay can
be extracted in the helper rather than each test function.
Pull Request #4452: MPP-3352: Add first Glean metrics to measure email mask usage

2084 of 3047 branches covered (68.4%)

Branch coverage included in aggregate %.

251 of 256 new or added lines in 7 files covered. (98.05%)

79 existing lines in 3 files now uncovered.

6763 of 8791 relevant lines covered (76.93%)

20.12 hits per line

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

96.03
/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 DomainAddrUnavailableException(CannotMakeAddressException):
1✔
690
    default_code = "address_unavailable"
1✔
691
    default_detail_template = (
1✔
692
        "“{unavailable_address}” could not be created."
693
        " Please try again with a different mask name."
694
    )
695
    status_code = 400
1✔
696

697
    def __init__(self, unavailable_address: str, *args, **kwargs):
1✔
698
        self.unavailable_address = unavailable_address
1✔
699
        super().__init__(*args, **kwargs)
1✔
700

701
    def error_context(self) -> ErrorContextType:
1✔
702
        return {"unavailable_address": self.unavailable_address}
1✔
703

704

705
class DomainAddrDuplicateException(CannotMakeAddressException):
1✔
706
    default_code = "duplicate_address"
1✔
707
    default_detail_template = (
1✔
708
        "“{duplicate_address}” already exists."
709
        " Please try again with a different mask name."
710
    )
711
    status_code = 409
1✔
712

713
    def __init__(self, duplicate_address: str, *args, **kwargs):
1✔
714
        self.duplicate_address = duplicate_address
1✔
715
        super().__init__(*args, **kwargs)
1✔
716

717
    def error_context(self) -> ErrorContextType:
1✔
718
        return {"duplicate_address": self.duplicate_address}
1✔
719

720

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

741
    def __str__(self):
1✔
742
        return self.address
×
743

744
    def delete(self, *args, **kwargs):
1✔
745
        # TODO: create hard bounce receipt rule in AWS for the address
746
        deleted_address = DeletedAddress.objects.create(
1✔
747
            address_hash=address_hash(self.address, domain=self.domain_value),
748
            num_forwarded=self.num_forwarded,
749
            num_blocked=self.num_blocked,
750
            num_replied=self.num_replied,
751
            num_spam=self.num_spam,
752
        )
753
        deleted_address.save()
1✔
754
        profile = Profile.objects.get(user=self.user)
1✔
755
        profile.address_last_deleted = datetime.now(timezone.utc)
1✔
756
        profile.num_address_deleted += 1
1✔
757
        profile.num_email_forwarded_in_deleted_address += self.num_forwarded
1✔
758
        profile.num_email_blocked_in_deleted_address += self.num_blocked
1✔
759
        profile.num_level_one_trackers_blocked_in_deleted_address = (
1✔
760
            profile.num_level_one_trackers_blocked_in_deleted_address or 0
761
        ) + (self.num_level_one_trackers_blocked or 0)
762
        profile.num_email_replied_in_deleted_address += self.num_replied
1✔
763
        profile.num_email_spam_in_deleted_address += self.num_spam
1✔
764
        profile.num_deleted_relay_addresses += 1
1✔
765
        profile.last_engagement = datetime.now(timezone.utc)
1✔
766
        profile.save()
1✔
767
        return super(RelayAddress, self).delete(*args, **kwargs)
1✔
768

769
    def save(
1✔
770
        self,
771
        force_insert: bool = False,
772
        force_update: bool = False,
773
        using: str | None = None,
774
        update_fields: Iterable[str] | None = None,
775
    ) -> None:
776
        if self._state.adding:
1✔
777
            with transaction.atomic():
1✔
778
                locked_profile = Profile.objects.select_for_update().get(user=self.user)
1✔
779
                check_user_can_make_another_address(locked_profile)
1✔
780
                while True:
1✔
781
                    address_is_allowed = not is_blocklisted(self.address)
1✔
782
                    address_is_valid = valid_address(self.address, self.domain_value)
1✔
783
                    if address_is_valid and address_is_allowed:
1✔
784
                        break
1✔
785
                    self.address = address_default()
1✔
786
                locked_profile.update_abuse_metric(address_created=True)
1✔
787
                locked_profile.last_engagement = datetime.now(timezone.utc)
1✔
788
                locked_profile.save()
1✔
789
        if (not self.user.profile.server_storage) and any(
1✔
790
            (self.description, self.generated_for, self.used_on)
791
        ):
792
            self.description = ""
1✔
793
            self.generated_for = ""
1✔
794
            self.used_on = ""
1✔
795
            if update_fields is not None:
1✔
796
                update_fields = {"description", "generated_for", "used_on"}.union(
1✔
797
                    update_fields
798
                )
799
        if not self.user.profile.has_premium and self.block_list_emails:
1✔
800
            self.block_list_emails = False
1✔
801
            if update_fields is not None:
1✔
802
                update_fields = {"block_list_emails"}.union(update_fields)
1✔
803
        super().save(
1✔
804
            force_insert=force_insert,
805
            force_update=force_update,
806
            using=using,
807
            update_fields=update_fields,
808
        )
809

810
    @property
1✔
811
    def domain_value(self):
1✔
812
        return get_domains_from_settings().get(self.get_domain_display())
1✔
813

814
    @property
1✔
815
    def full_address(self):
1✔
816
        return "%s@%s" % (self.address, self.domain_value)
1✔
817

818
    @property
1✔
819
    def metrics_id(self) -> str:
1✔
820
        assert self.id
1✔
821
        return f"R{self.id}"
1✔
822

823

824
def check_user_can_make_another_address(profile: Profile) -> None:
1✔
825
    if profile.is_flagged:
1✔
826
        raise AccountIsPausedException()
1✔
827
    # MPP-3021: return early for premium users to avoid at_max_free_aliases DB query
828
    if profile.has_premium:
1✔
829
        return
1✔
830
    if profile.at_max_free_aliases:
1✔
831
        raise RelayAddrFreeTierLimitException()
1✔
832

833

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

840

841
def valid_address(address: str, domain: str, subdomain: str | None = None) -> bool:
1✔
842
    address_pattern_valid = valid_address_pattern(address)
1✔
843
    address_contains_badword = has_bad_words(address)
1✔
844
    address_already_deleted = 0
1✔
845
    if not subdomain or flag_is_active_in_task(
1✔
846
        "custom_domain_management_redesign", None
847
    ):
848
        address_already_deleted = DeletedAddress.objects.filter(
1✔
849
            address_hash=address_hash(address, domain=domain, subdomain=subdomain)
850
        ).count()
851
    if (
1✔
852
        address_already_deleted > 0
853
        or address_contains_badword
854
        or not address_pattern_valid
855
    ):
856
        return False
1✔
857
    return True
1✔
858

859

860
class DeletedAddress(models.Model):
1✔
861
    address_hash = models.CharField(max_length=64, db_index=True)
1✔
862
    num_forwarded = models.PositiveIntegerField(default=0)
1✔
863
    num_blocked = models.PositiveIntegerField(default=0)
1✔
864
    num_replied = models.PositiveIntegerField(default=0)
1✔
865
    num_spam = models.PositiveIntegerField(default=0)
1✔
866

867
    def __str__(self):
1✔
UNCOV
868
        return self.address_hash
×
869

870

871
def check_user_can_make_domain_address(user_profile: Profile) -> None:
1✔
872
    if not user_profile.has_premium:
1✔
873
        raise DomainAddrFreeTierException()
1✔
874

875
    if not user_profile.subdomain:
1✔
876
        raise DomainAddrNeedSubdomainException()
1✔
877

878
    if user_profile.is_flagged:
1✔
879
        raise AccountIsPausedException()
1✔
880

881

882
class DomainAddress(models.Model):
1✔
883
    user = models.ForeignKey(User, on_delete=models.CASCADE)
1✔
884
    address = models.CharField(
1✔
885
        max_length=64, validators=[MinLengthValidator(limit_value=1)]
886
    )
887
    enabled = models.BooleanField(default=True)
1✔
888
    description = models.CharField(max_length=64, blank=True)
1✔
889
    domain = models.PositiveSmallIntegerField(choices=DOMAIN_CHOICES, default=2)
1✔
890
    created_at = models.DateTimeField(auto_now_add=True, db_index=True)
1✔
891
    first_emailed_at = models.DateTimeField(null=True, db_index=True)
1✔
892
    last_modified_at = models.DateTimeField(auto_now=True, db_index=True)
1✔
893
    last_used_at = models.DateTimeField(blank=True, null=True)
1✔
894
    num_forwarded = models.PositiveIntegerField(default=0)
1✔
895
    num_blocked = models.PositiveIntegerField(default=0)
1✔
896
    num_level_one_trackers_blocked = models.PositiveIntegerField(default=0, null=True)
1✔
897
    num_replied = models.PositiveIntegerField(default=0)
1✔
898
    num_spam = models.PositiveIntegerField(default=0)
1✔
899
    block_list_emails = models.BooleanField(default=False)
1✔
900
    used_on = models.TextField(default=None, blank=True, null=True)
1✔
901

902
    class Meta:
1✔
903
        unique_together = ["user", "address"]
1✔
904

905
    def __str__(self):
1✔
UNCOV
906
        return self.address
×
907

908
    def save(
1✔
909
        self,
910
        force_insert: bool = False,
911
        force_update: bool = False,
912
        using: str | None = None,
913
        update_fields: Iterable[str] | None = None,
914
    ) -> None:
915
        user_profile = self.user.profile
1✔
916
        if self._state.adding:
1✔
917
            check_user_can_make_domain_address(user_profile)
1✔
918
            domain_address_valid = valid_address(
1✔
919
                self.address, self.domain_value, user_profile.subdomain
920
            )
921
            if not domain_address_valid:
1✔
922
                if self.first_emailed_at:
1!
UNCOV
923
                    incr_if_enabled("domainaddress.create_via_email_fail")
×
924
                raise DomainAddrUnavailableException(unavailable_address=self.address)
1✔
925

926
            if DomainAddress.objects.filter(
1✔
927
                user=self.user, address=self.address
928
            ).exists():
929
                raise DomainAddrDuplicateException(duplicate_address=self.address)
1✔
930

931
            user_profile.update_abuse_metric(address_created=True)
1✔
932
            user_profile.last_engagement = datetime.now(timezone.utc)
1✔
933
            user_profile.save(update_fields=["last_engagement"])
1✔
934
            incr_if_enabled("domainaddress.create")
1✔
935
            if self.first_emailed_at:
1✔
936
                incr_if_enabled("domainaddress.create_via_email")
1✔
937
        if not user_profile.has_premium and self.block_list_emails:
1✔
938
            self.block_list_emails = False
1✔
939
            if update_fields:
1✔
940
                update_fields = {"block_list_emails"}.union(update_fields)
1✔
941
        if (not user_profile.server_storage) and (self.description or self.used_on):
1✔
942
            self.description = ""
1✔
943
            self.used_on = ""
1✔
944
            if update_fields:
1✔
945
                update_fields = {"description", "used_on"}.union(update_fields)
1✔
946
        super().save(
1✔
947
            force_insert=force_insert,
948
            force_update=force_update,
949
            using=using,
950
            update_fields=update_fields,
951
        )
952

953
    @property
1✔
954
    def user_profile(self):
1✔
955
        return Profile.objects.get(user=self.user)
1✔
956

957
    @staticmethod
1✔
958
    def make_domain_address(
1✔
959
        user_profile: Profile, address: str | None = None, made_via_email: bool = False
960
    ) -> "DomainAddress":
961
        check_user_can_make_domain_address(user_profile)
1✔
962

963
        if not address:
1✔
964
            # FIXME: if the alias is randomly generated and has bad words
965
            # we should retry like make_relay_address does
966
            # not fixing this now because not sure randomly generated
967
            # DomainAlias will be a feature
968
            address = address_default()
1✔
969
            # Only check for bad words if randomly generated
970
        assert isinstance(address, str)
1✔
971

972
        first_emailed_at = datetime.now(timezone.utc) if made_via_email else None
1✔
973
        domain_address = DomainAddress.objects.create(
1✔
974
            user=user_profile.user, address=address, first_emailed_at=first_emailed_at
975
        )
976
        return domain_address
1✔
977

978
    def delete(self, *args, **kwargs):
1✔
979
        # TODO: create hard bounce receipt rule in AWS for the address
980
        deleted_address = DeletedAddress.objects.create(
1✔
981
            address_hash=address_hash(
982
                self.address, self.user_profile.subdomain, self.domain_value
983
            ),
984
            num_forwarded=self.num_forwarded,
985
            num_blocked=self.num_blocked,
986
            num_replied=self.num_replied,
987
            num_spam=self.num_spam,
988
        )
989
        deleted_address.save()
1✔
990
        # self.user_profile is a property and should not be used to
991
        # update values on the user's profile
992
        profile = Profile.objects.get(user=self.user)
1✔
993
        profile.address_last_deleted = datetime.now(timezone.utc)
1✔
994
        profile.num_address_deleted += 1
1✔
995
        profile.num_email_forwarded_in_deleted_address += self.num_forwarded
1✔
996
        profile.num_email_blocked_in_deleted_address += self.num_blocked
1✔
997
        profile.num_level_one_trackers_blocked_in_deleted_address = (
1✔
998
            profile.num_level_one_trackers_blocked_in_deleted_address or 0
999
        ) + (self.num_level_one_trackers_blocked or 0)
1000
        profile.num_email_replied_in_deleted_address += self.num_replied
1✔
1001
        profile.num_email_spam_in_deleted_address += self.num_spam
1✔
1002
        profile.num_deleted_domain_addresses += 1
1✔
1003
        profile.last_engagement = datetime.now(timezone.utc)
1✔
1004
        profile.save()
1✔
1005
        return super(DomainAddress, self).delete(*args, **kwargs)
1✔
1006

1007
    @property
1✔
1008
    def domain_value(self):
1✔
1009
        return get_domains_from_settings().get(self.get_domain_display())
1✔
1010

1011
    @property
1✔
1012
    def full_address(self):
1✔
1013
        return "%s@%s.%s" % (
1✔
1014
            self.address,
1015
            self.user_profile.subdomain,
1016
            self.domain_value,
1017
        )
1018

1019
    @property
1✔
1020
    def metrics_id(self) -> str:
1✔
1021
        assert self.id
1✔
1022
        return f"D{self.id}"
1✔
1023

1024

1025
class Reply(models.Model):
1✔
1026
    relay_address = models.ForeignKey(
1✔
1027
        RelayAddress, on_delete=models.CASCADE, blank=True, null=True
1028
    )
1029
    domain_address = models.ForeignKey(
1✔
1030
        DomainAddress, on_delete=models.CASCADE, blank=True, null=True
1031
    )
1032
    lookup = models.CharField(max_length=255, blank=False, db_index=True)
1✔
1033
    encrypted_metadata = models.TextField(blank=False)
1✔
1034
    created_at = models.DateField(auto_now_add=True, null=False, db_index=True)
1✔
1035

1036
    @property
1✔
1037
    def address(self):
1✔
1038
        return self.relay_address or self.domain_address
1✔
1039

1040
    @property
1✔
1041
    def profile(self):
1✔
1042
        return self.address.user.profile
1✔
1043

1044
    @property
1✔
1045
    def owner_has_premium(self):
1✔
1046
        return self.profile.has_premium
1✔
1047

1048
    def increment_num_replied(self):
1✔
1049
        address = self.relay_address or self.domain_address
1✔
1050
        assert address
1✔
1051
        address.num_replied += 1
1✔
1052
        address.last_used_at = datetime.now(timezone.utc)
1✔
1053
        address.save(update_fields=["num_replied", "last_used_at"])
1✔
1054
        return address.num_replied
1✔
1055

1056

1057
class AbuseMetrics(models.Model):
1✔
1058
    user = models.ForeignKey(User, on_delete=models.CASCADE)
1✔
1059
    first_recorded = models.DateTimeField(auto_now_add=True, db_index=True)
1✔
1060
    last_recorded = models.DateTimeField(auto_now_add=True, db_index=True)
1✔
1061
    num_address_created_per_day = models.PositiveSmallIntegerField(default=0)
1✔
1062
    num_replies_per_day = models.PositiveSmallIntegerField(default=0)
1✔
1063
    # Values from 0 to 32767 are safe in all databases supported by Django.
1064
    num_email_forwarded_per_day = models.PositiveSmallIntegerField(default=0)
1✔
1065
    # Values from 0 to 9.2 exabytes are safe in all databases supported by Django.
1066
    forwarded_email_size_per_day = models.PositiveBigIntegerField(default=0)
1✔
1067

1068
    class Meta:
1✔
1069
        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