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

mozilla / fx-private-relay / d3128616-238d-446e-82c5-ab66cd38ceaf

09 May 2024 06:22PM CUT coverage: 84.07% (-0.6%) from 84.64%
d3128616-238d-446e-82c5-ab66cd38ceaf

push

circleci

web-flow
Merge pull request #4684 from mozilla/enable-flak8-bandit-checks-mpp-3802

fix MPP-3802: stop ignoring bandit security checks

3601 of 4734 branches covered (76.07%)

Branch coverage included in aggregate %.

74 of 158 new or added lines in 24 files covered. (46.84%)

5 existing lines in 5 files now uncovered.

14686 of 17018 relevant lines covered (86.3%)

10.86 hits per line

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

90.31
/api/views/phones.py
1
from __future__ import annotations
1✔
2

3
import hashlib
1✔
4
import logging
1✔
5
import re
1✔
6
import string
1✔
7
from dataclasses import asdict, dataclass, field
1✔
8
from datetime import UTC, datetime
1✔
9
from typing import Any, Literal
1✔
10

11
from django.conf import settings
1✔
12
from django.contrib.auth.models import User
1✔
13
from django.core.exceptions import ObjectDoesNotExist
1✔
14
from django.db.models.query import QuerySet
1✔
15
from django.forms import model_to_dict
1✔
16

17
import django_ftl
1✔
18
import phonenumbers
1✔
19
from drf_spectacular.utils import (
1✔
20
    OpenApiExample,
21
    OpenApiParameter,
22
    OpenApiRequest,
23
    OpenApiResponse,
24
    extend_schema,
25
)
26
from rest_framework import (
1✔
27
    decorators,
28
    exceptions,
29
    permissions,
30
    response,
31
    throttling,
32
    viewsets,
33
)
34
from rest_framework.generics import get_object_or_404
1✔
35
from rest_framework.request import Request
1✔
36
from twilio.base.exceptions import TwilioRestException
1✔
37
from waffle import flag_is_active, get_waffle_flag_model
1✔
38

39
from api.views import SaveToRequestUser
1✔
40
from emails.utils import incr_if_enabled
1✔
41
from phones.apps import phones_config, twilio_client
1✔
42
from phones.iq_utils import send_iq_sms
1✔
43
from phones.models import (
1✔
44
    InboundContact,
45
    RealPhone,
46
    RelayNumber,
47
    area_code_numbers,
48
    get_last_text_sender,
49
    get_pending_unverified_realphone_records,
50
    get_valid_realphone_verification_record,
51
    get_verified_realphone_record,
52
    get_verified_realphone_records,
53
    location_numbers,
54
    send_welcome_message,
55
    suggested_numbers,
56
)
57
from privaterelay.ftl_bundles import main as ftl_bundle
1✔
58

59
from ..exceptions import ConflictError, ErrorContextType
1✔
60
from ..permissions import HasPhoneService
1✔
61
from ..renderers import TemplateTwiMLRenderer, vCardRenderer
1✔
62
from ..serializers.phones import (
1✔
63
    InboundContactSerializer,
64
    IqInboundSmsSerializer,
65
    OutboundCallSerializer,
66
    OutboundSmsSerializer,
67
    RealPhoneSerializer,
68
    RelayNumberSerializer,
69
    TwilioInboundCallSerializer,
70
    TwilioInboundSmsSerializer,
71
    TwilioMessagesSerializer,
72
    TwilioNumberSuggestion,
73
    TwilioNumberSuggestionGroups,
74
    TwilioSmsStatusSerializer,
75
    TwilioVoiceStatusSerializer,
76
)
77

78
logger = logging.getLogger("events")
1✔
79
info_logger = logging.getLogger("eventsinfo")
1✔
80

81

82
def twilio_validator():
1✔
83
    return phones_config().twilio_validator
1✔
84

85

86
def twiml_app():
1✔
87
    return phones_config().twiml_app
×
88

89

90
class RealPhoneRateThrottle(throttling.UserRateThrottle):
1✔
91
    rate = settings.PHONE_RATE_LIMIT
1✔
92

93

94
@extend_schema(tags=["phones"])
1✔
95
class RealPhoneViewSet(SaveToRequestUser, viewsets.ModelViewSet):
1✔
96
    """
97
    Get real phone number records for the authenticated user.
98

99
    The authenticated user must have a subscription that grants one of the
100
    `SUBSCRIPTIONS_WITH_PHONE` capabilities.
101

102
    Client must be authenticated, and these endpoints only return data that is
103
    "owned" by the authenticated user.
104

105
    All endpoints are rate-limited to settings.PHONE_RATE_LIMIT
106
    """
107

108
    http_method_names = ["get", "post", "patch", "delete"]
1✔
109
    permission_classes = [permissions.IsAuthenticated, HasPhoneService]
1✔
110
    serializer_class = RealPhoneSerializer
1✔
111
    # TODO: this doesn't seem to e working?
112
    throttle_classes = [RealPhoneRateThrottle]
1✔
113

114
    def get_queryset(self) -> QuerySet[RealPhone]:
1✔
115
        if isinstance(self.request.user, User):
1✔
116
            return RealPhone.objects.filter(user=self.request.user)
1✔
117
        return RealPhone.objects.none()
1✔
118

119
    def create(self, request):
1✔
120
        """
121
        Add real phone number to the authenticated user.
122

123
        The "flow" to verify a real phone number is:
124
        1. POST a number (Will text a verification code to the number)
125
        2a. PATCH the verification code to the realphone/{id} endpoint
126
        2b. POST the number and verification code together
127

128
        The authenticated user must have a subscription that grants one of the
129
        `SUBSCRIPTIONS_WITH_PHONE` capabilities.
130

131
        The `number` field should be in [E.164][e164] format which includes a country
132
        code. If the number is not in E.164 format, this endpoint will try to
133
        create an E.164 number by prepending the country code of the client
134
        making the request (i.e., from the `X-Client-Region` HTTP header).
135

136
        If the `POST` does NOT include a `verification_code` and the number is
137
        a valid (currently, US-based) number, this endpoint will text a
138
        verification code to the number.
139

140
        If the `POST` DOES include a `verification_code`, and the code matches
141
        a code already sent to the number, this endpoint will set `verified` to
142
        `True` for this number.
143

144
        [e164]: https://en.wikipedia.org/wiki/E.164
145
        """
146
        incr_if_enabled("phones_RealPhoneViewSet.create")
1✔
147
        serializer = self.get_serializer(data=request.data)
1✔
148
        serializer.is_valid(raise_exception=True)
1✔
149

150
        # Check if the request includes a valid verification_code
151
        # value, look for any un-expired record that matches both the phone
152
        # number and verification code and mark it verified.
153
        verification_code = serializer.validated_data.get("verification_code")
1✔
154
        if verification_code:
1✔
155
            valid_record = get_valid_realphone_verification_record(
1✔
156
                request.user, serializer.validated_data["number"], verification_code
157
            )
158
            if not valid_record:
1✔
159
                incr_if_enabled("phones_RealPhoneViewSet.create.invalid_verification")
1✔
160
                raise exceptions.ValidationError(
1✔
161
                    "Could not find that verification_code for user and number."
162
                    " It may have expired."
163
                )
164

165
            headers = self.get_success_headers(serializer.validated_data)
1✔
166
            verified_valid_record = valid_record.mark_verified()
1✔
167
            incr_if_enabled("phones_RealPhoneViewSet.create.mark_verified")
1✔
168
            response_data = model_to_dict(
1✔
169
                verified_valid_record,
170
                fields=[
171
                    "id",
172
                    "number",
173
                    "verification_sent_date",
174
                    "verified",
175
                    "verified_date",
176
                ],
177
            )
178
            return response.Response(response_data, status=201, headers=headers)
1✔
179

180
        # to prevent sending verification codes to verified numbers,
181
        # check if the number is already a verified number.
182
        is_verified = get_verified_realphone_record(serializer.validated_data["number"])
1✔
183
        if is_verified:
1!
184
            raise ConflictError("A verified record already exists for this number.")
×
185

186
        # to prevent abusive sending of verification messages,
187
        # check if there is an un-expired verification code for the user
188
        pending_unverified_records = get_pending_unverified_realphone_records(
1✔
189
            serializer.validated_data["number"]
190
        )
191
        if pending_unverified_records:
1✔
192
            raise ConflictError(
1✔
193
                "An unverified record already exists for this number.",
194
            )
195

196
        # We call an additional _validate_number function with the request
197
        # to try to parse the number as a local national number in the
198
        # request.country attribute
199
        valid_number = _validate_number(request)
1✔
200
        serializer.validated_data["number"] = valid_number.phone_number
1✔
201
        serializer.validated_data["country_code"] = valid_number.country_code.upper()
1✔
202

203
        self.perform_create(serializer)
1✔
204
        incr_if_enabled("phones_RealPhoneViewSet.perform_create")
1✔
205
        headers = self.get_success_headers(serializer.validated_data)
1✔
206
        response_data = serializer.data
1✔
207
        response_data["message"] = (
1✔
208
            "Sent verification code to "
209
            f"{valid_number.phone_number} "
210
            f"(country: {valid_number.country_code} "
211
            f"carrier: {valid_number.carrier})"
212
        )
213
        return response.Response(response_data, status=201, headers=headers)
1✔
214

215
    # check verification_code during partial_update to compare
216
    # the value sent in the request against the value already on the instance
217
    # TODO: this logic might be able to move "up" into the model, but it will
218
    # need some more serious refactoring of the RealPhone.save() method
219
    def partial_update(self, request, *args, **kwargs):
1✔
220
        """
221
        Update the authenticated user's real phone number.
222

223
        The authenticated user must have a subscription that grants one of the
224
        `SUBSCRIPTIONS_WITH_PHONE` capabilities.
225

226
        The `{id}` should match a previously-`POST`ed resource that belongs to the user.
227

228
        The `number` field should be in [E.164][e164] format which includes a country
229
        code.
230

231
        The `verification_code` should be the code that was texted to the
232
        number during the `POST`. If it matches, this endpoint will set
233
        `verified` to `True` for this number.
234

235
        [e164]: https://en.wikipedia.org/wiki/E.164
236
        """
237
        incr_if_enabled("phones_RealPhoneViewSet.partial_update")
1✔
238
        instance = self.get_object()
1✔
239
        if request.data["number"] != instance.number:
1✔
240
            raise exceptions.ValidationError("Invalid number for ID.")
1✔
241
        # TODO: check verification_sent_date is not "expired"?
242
        # Note: the RealPhone.save() logic should prevent expired verifications
243
        if (
1✔
244
            "verification_code" not in request.data
245
            or not request.data["verification_code"] == instance.verification_code
246
        ):
247
            raise exceptions.ValidationError(
1✔
248
                "Invalid verification_code for ID. It may have expired."
249
            )
250

251
        instance.mark_verified()
1✔
252
        incr_if_enabled("phones_RealPhoneViewSet.partial_update.mark_verified")
1✔
253
        return super().partial_update(request, *args, **kwargs)
1✔
254

255
    def destroy(self, request, *args, **kwargs):
1✔
256
        """
257
        Delete a real phone resource.
258

259
        Only **un-verified** real phone resources can be deleted.
260
        """
261
        incr_if_enabled("phones_RealPhoneViewSet.destroy")
1✔
262
        instance = self.get_object()
1✔
263
        if instance.verified:
1✔
264
            raise exceptions.ValidationError(
1✔
265
                "Only un-verified real phone resources can be deleted."
266
            )
267

268
        return super().destroy(request, *args, **kwargs)
1✔
269

270

271
@extend_schema(tags=["phones"])
1✔
272
class RelayNumberViewSet(SaveToRequestUser, viewsets.ModelViewSet):
1✔
273
    http_method_names = ["get", "post", "patch"]
1✔
274
    permission_classes = [permissions.IsAuthenticated, HasPhoneService]
1✔
275
    serializer_class = RelayNumberSerializer
1✔
276

277
    def get_queryset(self) -> QuerySet[RelayNumber]:
1✔
278
        if isinstance(self.request.user, User):
1✔
279
            return RelayNumber.objects.filter(user=self.request.user)
1✔
280
        return RelayNumber.objects.none()
1✔
281

282
    def create(self, request, *args, **kwargs):
1✔
283
        """
284
        Provision a phone number with Twilio and assign to the authenticated user.
285

286
        ⚠️ **THIS WILL BUY A PHONE NUMBER** ⚠️
287
        If you have real account credentials in your `TWILIO_*` env vars, this
288
        will really provision a Twilio number to your account. You can use
289
        [Test Credentials][test-creds] to call this endpoint without making a
290
        real phone number purchase. If you do, you need to pass one of the
291
        [test phone numbers][test-numbers].
292

293
        The `number` should be in [E.164][e164] format.
294

295
        Every call or text to the relay number will be sent as a webhook to the
296
        URL configured for your `TWILIO_SMS_APPLICATION_SID`.
297

298
        [test-creds]: https://www.twilio.com/docs/iam/test-credentials
299
        [test-numbers]: https://www.twilio.com/docs/iam/test-credentials#test-incoming-phone-numbers-parameters-PhoneNumber
300
        [e164]: https://en.wikipedia.org/wiki/E.164
301
        """  # noqa: E501  # ignore long line for URL
302
        incr_if_enabled("phones_RelayNumberViewSet.create")
1✔
303
        existing_number = RelayNumber.objects.filter(user=request.user)
1✔
304
        if existing_number:
1!
305
            raise exceptions.ValidationError("User already has a RelayNumber.")
1✔
306
        return super().create(request, *args, **kwargs)
×
307

308
    def partial_update(self, request, *args, **kwargs):
1✔
309
        """
310
        Update the authenticated user's relay number.
311

312
        The authenticated user must have a subscription that grants one of the
313
        `SUBSCRIPTIONS_WITH_PHONE` capabilities.
314

315
        The `{id}` should match a previously-`POST`ed resource that belongs to
316
        the authenticated user.
317

318
        This is primarily used to toggle the `enabled` field.
319
        """
320
        incr_if_enabled("phones_RelayNumberViewSet.partial_update")
1✔
321
        return super().partial_update(request, *args, **kwargs)
1✔
322

323
    @extend_schema(
1✔
324
        responses={
325
            "200": OpenApiResponse(
326
                TwilioNumberSuggestionGroups(),
327
                description="Suggested numbers based on the user's real number",
328
                examples=[
329
                    OpenApiExample(
330
                        "suggestions",
331
                        {
332
                            "real_num": "4045556789",
333
                            "same_prefix_options": [],
334
                            "other_areas_options": [],
335
                            "same_area_options": [],
336
                            "random_options": [
337
                                {
338
                                    "friendly_name": "(256) 555-3456",
339
                                    "iso_country": "US",
340
                                    "locality": "Gadsden",
341
                                    "phone_number": "+12565553456",
342
                                    "postal_code": "35903",
343
                                    "region": "AL",
344
                                }
345
                            ],
346
                        },
347
                    )
348
                ],
349
            ),
350
            "400": OpenApiResponse(
351
                description=(
352
                    "User has not verified their real number,"
353
                    " or already has a Relay number."
354
                )
355
            ),
356
        },
357
    )
358
    @decorators.action(detail=False)
1✔
359
    def suggestions(self, request):
1✔
360
        """
361
        Returns suggested relay numbers for the authenticated user.
362

363
        Based on the user's real number, returns available relay numbers:
364
          * `same_prefix_options`: Numbers that match as much of the user's
365
            real number as possible.
366
          * `other_areas_options`: Numbers that exactly match the user's real
367
            number, in a different area code.
368
          * `same_area_options`: Other numbers in the same area code as the user.
369
          * `random_options`: Available numbers in the user's country
370
        """
371
        incr_if_enabled("phones_RelayNumberViewSet.suggestions")
1✔
372
        numbers = suggested_numbers(request.user)
1✔
373
        return response.Response(numbers)
1✔
374

375
    @extend_schema(
1✔
376
        parameters=[
377
            OpenApiParameter(
378
                "location",
379
                required=False,
380
                location="query",
381
                examples=[OpenApiExample("Miami FL USA", "Miami")],
382
            ),
383
            OpenApiParameter(
384
                "area_code",
385
                required=False,
386
                location="query",
387
                examples=[OpenApiExample("Tulsa OK USA", "918")],
388
            ),
389
        ],
390
        responses={
391
            "200": OpenApiResponse(
392
                TwilioNumberSuggestion(many=True),
393
                description="List of available numbers",
394
                examples=[
395
                    OpenApiExample(
396
                        "Tulsa, OK",
397
                        {
398
                            "friendly_name": "(918) 555-6789",
399
                            "iso_country": "US",
400
                            "locality": "Tulsa",
401
                            "phone_number": "+19185556789",
402
                            "postal_code": "74120",
403
                            "region": "OK",
404
                        },
405
                    )
406
                ],
407
            ),
408
            "404": OpenApiResponse(
409
                description="Neither location or area_code was speciifed"
410
            ),
411
        },
412
    )
413
    @decorators.action(detail=False)
1✔
414
    def search(self, request):
1✔
415
        """
416
        Search for available numbers.
417

418
        This endpoints uses the underlying [AvailablePhoneNumbers][apn] API.
419

420
        Accepted query params:
421
          * ?location=
422
            * Will be passed to `AvailablePhoneNumbers` `in_locality` param
423
          * ?area_code=
424
            * Will be passed to `AvailablePhoneNumbers` `area_code` param
425

426
        [apn]: https://www.twilio.com/docs/phone-numbers/api/availablephonenumberlocal-resource#read-multiple-availablephonenumberlocal-resources
427
        """  # noqa: E501  # ignore long line for URL
428
        incr_if_enabled("phones_RelayNumberViewSet.search")
1✔
429
        real_phone = get_verified_realphone_records(request.user).first()
1✔
430
        if real_phone:
1✔
431
            country_code = real_phone.country_code
1✔
432
        else:
433
            country_code = "US"
1✔
434
        location = request.query_params.get("location")
1✔
435
        if location is not None:
1✔
436
            numbers = location_numbers(location, country_code)
1✔
437
            return response.Response(numbers)
1✔
438

439
        area_code = request.query_params.get("area_code")
1✔
440
        if area_code is not None:
1✔
441
            numbers = area_code_numbers(area_code, country_code)
1✔
442
            return response.Response(numbers)
1✔
443

444
        return response.Response({}, 404)
1✔
445

446

447
@extend_schema(tags=["phones"])
1✔
448
class InboundContactViewSet(viewsets.ModelViewSet):
1✔
449
    http_method_names = ["get", "patch"]
1✔
450
    permission_classes = [permissions.IsAuthenticated, HasPhoneService]
1✔
451
    serializer_class = InboundContactSerializer
1✔
452

453
    def get_queryset(self) -> QuerySet[InboundContact]:
1✔
454
        if isinstance(self.request.user, User):
1✔
455
            relay_number = get_object_or_404(RelayNumber, user=self.request.user)
1✔
456
            return InboundContact.objects.filter(relay_number=relay_number)
1✔
457
        return InboundContact.objects.none()
1✔
458

459

460
def _validate_number(request, number_field="number"):
1✔
461
    if number_field not in request.data:
1✔
462
        raise exceptions.ValidationError({number_field: "A number is required."})
1✔
463

464
    parsed_number = _parse_number(
1✔
465
        request.data[number_field], getattr(request, "country", None)
466
    )
467
    if not parsed_number:
1✔
468
        country = None
1✔
469
        if hasattr(request, "country"):
1!
470
            country = request.country
×
471
        error_message = (
1✔
472
            "number must be in E.164 format, or in local national format of the"
473
            f" country detected: {country}"
474
        )
475
        raise exceptions.ValidationError(error_message)
1✔
476

477
    e164_number = f"+{parsed_number.country_code}{parsed_number.national_number}"
1✔
478
    number_details = _get_number_details(e164_number)
1✔
479
    if not number_details:
1✔
480
        raise exceptions.ValidationError(
1✔
481
            f"Could not get number details for {e164_number}"
482
        )
483

484
    if number_details.country_code.upper() not in settings.TWILIO_ALLOWED_COUNTRY_CODES:
1✔
485
        incr_if_enabled("phones_validate_number_unsupported_country")
1✔
486
        raise exceptions.ValidationError(
1✔
487
            "Relay Phone is currently only available for these country codes: "
488
            f"{sorted(settings.TWILIO_ALLOWED_COUNTRY_CODES)!r}. "
489
            "Your phone number country code is: "
490
            f"'{number_details.country_code.upper()}'."
491
        )
492

493
    return number_details
1✔
494

495

496
def _parse_number(number, country=None):
1✔
497
    try:
1✔
498
        # First try to parse assuming number is E.164 with country prefix
499
        return phonenumbers.parse(number)
1✔
500
    except phonenumbers.phonenumberutil.NumberParseException as e:
1✔
501
        if e.error_type == e.INVALID_COUNTRY_CODE and country is not None:
1✔
502
            try:
1✔
503
                # Try to parse, assuming number is local national format
504
                # in the detected request country
505
                return phonenumbers.parse(number, country)
1✔
506
            except Exception:
×
507
                return None
×
508
    return None
1✔
509

510

511
def _get_number_details(e164_number):
1✔
512
    incr_if_enabled("phones_get_number_details")
1✔
513
    try:
1✔
514
        client = twilio_client()
1✔
515
        return client.lookups.v1.phone_numbers(e164_number).fetch(type=["carrier"])
1✔
516
    except Exception:
1✔
517
        logger.exception(f"Could not get number details for {e164_number}")
1✔
518
        return None
1✔
519

520

521
@extend_schema(
1✔
522
    tags=["phones"],
523
    responses={
524
        "200": OpenApiResponse(
525
            bytes,
526
            description="A Virtual Contact File (VCF) for the user's Relay number.",
527
            examples=[
528
                OpenApiExample(
529
                    name="partial VCF",
530
                    media_type="text/x-vcard",
531
                    value=(
532
                        "BEGIN:VCARD\nVERSION:3.0\nFN:Firefox Relay\n"
533
                        "TEL:+14045555555\nEND:VCARD\n"
534
                    ),
535
                )
536
            ],
537
        ),
538
        "404": OpenApiResponse(description="No or unknown lookup key"),
539
    },
540
)
541
@decorators.api_view()
1✔
542
@decorators.permission_classes([permissions.AllowAny])
1✔
543
@decorators.renderer_classes([vCardRenderer])
1✔
544
def vCard(request: Request, lookup_key: str) -> response.Response:
1✔
545
    """
546
    Get a Relay vCard. `lookup_key` should be passed in url path.
547

548
    We use this to return a vCard for a number. When we create a RelayNumber,
549
    we create a secret lookup_key and text it to the user.
550
    """
551
    incr_if_enabled("phones_vcard")
1✔
552
    if lookup_key is None:
1!
553
        return response.Response(status=404)
×
554

555
    try:
1✔
556
        relay_number = RelayNumber.objects.get(vcard_lookup_key=lookup_key)
1✔
557
    except RelayNumber.DoesNotExist:
1✔
558
        raise exceptions.NotFound()
1✔
559
    number = relay_number.number
1✔
560

561
    resp = response.Response({"number": number})
1✔
562
    resp["Content-Disposition"] = f"attachment; filename={number}.vcf"
1✔
563
    return resp
1✔
564

565

566
@extend_schema(
1✔
567
    tags=["phones"],
568
    request=OpenApiRequest(),
569
    responses={
570
        "200": OpenApiResponse(
571
            {"type": "object"},
572
            description="Welcome message sent.",
573
            examples=[OpenApiExample("success", {"msg": "sent"})],
574
        ),
575
        "401": OpenApiResponse(description="Not allowed"),
576
        "404": OpenApiResponse(description="User does not have a Relay number."),
577
    },
578
)
579
@decorators.api_view(["POST"])
1✔
580
@decorators.permission_classes([permissions.IsAuthenticated, HasPhoneService])
1✔
581
def resend_welcome_sms(request):
1✔
582
    """
583
    Resend the "Welcome" SMS, including vCard.
584

585
    Requires the user to be signed in and to have phone service.
586
    """
587
    incr_if_enabled("phones_resend_welcome_sms")
1✔
588
    try:
1✔
589
        relay_number = RelayNumber.objects.get(user=request.user)
1✔
590
    except RelayNumber.DoesNotExist:
×
591
        raise exceptions.NotFound()
×
592
    send_welcome_message(request.user, relay_number)
1✔
593

594
    resp = response.Response(status=201, data={"msg": "sent"})
1✔
595
    return resp
1✔
596

597

598
def _try_delete_from_twilio(message):
1✔
599
    try:
1✔
600
        message.delete()
1✔
601
    except TwilioRestException as e:
×
602
        # Raise the exception unless it's a 404 indicating the message is already gone
603
        if e.status != 404:
×
604
            raise e
×
605

606

607
def message_body(from_num, body):
1✔
608
    return f"[Relay 📲 {from_num}] {body}"
1✔
609

610

611
def _get_user_error_message(
1✔
612
    real_phone: RealPhone, sms_exception: RelaySMSException
613
) -> Any:
614
    # Send a translated message to the user
615
    ftl_code = sms_exception.get_codes().replace("_", "-")
1✔
616
    ftl_id = f"sms-error-{ftl_code}"
1✔
617
    # log the error in English
618
    with django_ftl.override("en"):
1✔
619
        logger.exception(ftl_bundle.format(ftl_id, sms_exception.error_context()))
1✔
620
    with django_ftl.override(real_phone.user.profile.language):
1✔
621
        user_message = ftl_bundle.format(ftl_id, sms_exception.error_context())
1✔
622
    return user_message
1✔
623

624

625
@extend_schema(
1✔
626
    tags=["phones: Twilio"],
627
    parameters=[
628
        OpenApiParameter(name="X-Twilio-Signature", required=True, location="header"),
629
    ],
630
    request=OpenApiRequest(
631
        TwilioInboundSmsSerializer,
632
        examples=[
633
            OpenApiExample(
634
                "request",
635
                {"to": "+13035556789", "from": "+14045556789", "text": "Hello!"},
636
            )
637
        ],
638
    ),
639
    responses={
640
        "200": OpenApiResponse(
641
            {"type": "string", "xml": {"name": "Response"}},
642
            description="The number is disabled.",
643
            examples=[OpenApiExample("disabled", None)],
644
        ),
645
        "201": OpenApiResponse(
646
            {"type": "string", "xml": {"name": "Response"}},
647
            description="Forward the message to the user.",
648
            examples=[OpenApiExample("success", None)],
649
        ),
650
        "400": OpenApiResponse(
651
            {"type": "object", "xml": {"name": "Error"}},
652
            description="Unable to complete request.",
653
            examples=[
654
                OpenApiExample(
655
                    "invalid signature",
656
                    {
657
                        "status_code": 400,
658
                        "code": "invalid",
659
                        "title": "Invalid Request: Invalid Signature",
660
                    },
661
                )
662
            ],
663
        ),
664
    },
665
)
666
@decorators.api_view(["POST"])
1✔
667
@decorators.permission_classes([permissions.AllowAny])
1✔
668
@decorators.renderer_classes([TemplateTwiMLRenderer])
1✔
669
def inbound_sms(request):
1✔
670
    """
671
    Handle an inbound SMS message sent by Twilio.
672

673
    The return value is TwilML Response XML that reports the error or an empty success
674
    message.
675
    """
676
    incr_if_enabled("phones_inbound_sms")
1✔
677
    _validate_twilio_request(request)
1✔
678

679
    """
1✔
680
    TODO: delete the message from Twilio; how to do this AFTER this request? queue?
681
    E.g., with a django-celery task in phones.tasks:
682

683
    inbound_msg_sid = request.data.get("MessageSid", None)
684
    if inbound_msg_sid is None:
685
        raise exceptions.ValidationError("Request missing MessageSid")
686
    tasks._try_delete_from_twilio.delay(args=message, countdown=10)
687
    """
688

689
    inbound_body = request.data.get("Body", None)
1✔
690
    inbound_from = request.data.get("From", None)
1✔
691
    inbound_to = request.data.get("To", None)
1✔
692
    if inbound_body is None or inbound_from is None or inbound_to is None:
1✔
693
        raise exceptions.ValidationError("Request missing From, To, Or Body.")
1✔
694

695
    relay_number, real_phone = _get_phone_objects(inbound_to)
1✔
696
    _check_remaining(relay_number, "texts")
1✔
697

698
    if inbound_from == real_phone.number:
1✔
699
        try:
1✔
700
            relay_number, destination_number, body = _prepare_sms_reply(
1✔
701
                relay_number, inbound_body
702
            )
703
            client = twilio_client()
1✔
704
            incr_if_enabled("phones_send_sms_reply")
1✔
705
            client.messages.create(
1✔
706
                from_=relay_number.number, body=body, to=destination_number
707
            )
708
            relay_number.remaining_texts -= 1
1✔
709
            relay_number.texts_forwarded += 1
1✔
710
            relay_number.save()
1✔
711
        except RelaySMSException as sms_exception:
1✔
712
            user_error_message = _get_user_error_message(real_phone, sms_exception)
1✔
713
            twilio_client().messages.create(
1✔
714
                from_=relay_number.number, body=user_error_message, to=real_phone.number
715
            )
716

717
            # Return 400 on critical exceptions
718
            if sms_exception.critical:
1✔
719
                raise exceptions.ValidationError(
1✔
720
                    sms_exception.detail
721
                ) from sms_exception
722
        return response.Response(
1✔
723
            status=200,
724
            template_name="twiml_empty_response.xml",
725
        )
726

727
    number_disabled = _check_disabled(relay_number, "texts")
1✔
728
    if number_disabled:
1✔
729
        return response.Response(
1✔
730
            status=200,
731
            template_name="twiml_empty_response.xml",
732
        )
733
    inbound_contact = _get_inbound_contact(relay_number, inbound_from)
1✔
734
    if inbound_contact:
1✔
735
        _check_and_update_contact(inbound_contact, "texts", relay_number)
1✔
736

737
    client = twilio_client()
1✔
738
    app = twiml_app()
1✔
739
    incr_if_enabled("phones_outbound_sms")
1✔
740
    body = message_body(inbound_from, inbound_body)
1✔
741
    client.messages.create(
1✔
742
        from_=relay_number.number,
743
        body=body,
744
        status_callback=app.sms_status_callback,
745
        to=real_phone.number,
746
    )
747
    relay_number.remaining_texts -= 1
1✔
748
    relay_number.texts_forwarded += 1
1✔
749
    relay_number.save()
1✔
750
    return response.Response(
1✔
751
        status=201,
752
        template_name="twiml_empty_response.xml",
753
    )
754

755

756
@extend_schema(
1✔
757
    tags=["phones: Inteliquent"],
758
    request=OpenApiRequest(
759
        IqInboundSmsSerializer,
760
        examples=[
761
            OpenApiExample(
762
                "request",
763
                {"to": "+13035556789", "from": "+14045556789", "text": "Hello!"},
764
            )
765
        ],
766
    ),
767
    parameters=[
768
        OpenApiParameter(name="VerificationToken", required=True, location="header"),
769
        OpenApiParameter(name="MessageId", required=True, location="header"),
770
    ],
771
    responses={
772
        "200": OpenApiResponse(
773
            description=(
774
                "The message was forwarded, or the user is out of text messages."
775
            )
776
        ),
777
        "401": OpenApiResponse(description="Invalid signature"),
778
        "400": OpenApiResponse(description="Invalid request"),
779
    },
780
)
781
@decorators.api_view(["POST"])
1✔
782
@decorators.permission_classes([permissions.AllowAny])
1✔
783
def inbound_sms_iq(request: Request) -> response.Response:
1✔
784
    """Handle an inbound SMS message sent by Inteliquent."""
785
    incr_if_enabled("phones_inbound_sms_iq")
×
786
    _validate_iq_request(request)
×
787

788
    inbound_body = request.data.get("text", None)
×
789
    inbound_from = request.data.get("from", None)
×
790
    inbound_to = request.data.get("to", None)
×
791
    if inbound_body is None or inbound_from is None or inbound_to is None:
×
792
        raise exceptions.ValidationError("Request missing from, to, or text.")
×
793

794
    from_num = phonenumbers.format_number(
×
795
        phonenumbers.parse(inbound_from, "US"),
796
        phonenumbers.PhoneNumberFormat.E164,
797
    )
798
    single_num = inbound_to[0]
×
799
    relay_num = phonenumbers.format_number(
×
800
        phonenumbers.parse(single_num, "US"), phonenumbers.PhoneNumberFormat.E164
801
    )
802

803
    relay_number, real_phone = _get_phone_objects(relay_num)
×
804
    _check_remaining(relay_number, "texts")
×
805

806
    if from_num == real_phone.number:
×
807
        try:
×
808
            relay_number, destination_number, body = _prepare_sms_reply(
×
809
                relay_number, inbound_body
810
            )
811
            send_iq_sms(destination_number, relay_number.number, body)
×
812
            relay_number.remaining_texts -= 1
×
813
            relay_number.texts_forwarded += 1
×
814
            relay_number.save()
×
815
            incr_if_enabled("phones_send_sms_reply_iq")
×
816
        except RelaySMSException as sms_exception:
×
817
            user_error_message = _get_user_error_message(real_phone, sms_exception)
×
818
            send_iq_sms(real_phone.number, relay_number.number, user_error_message)
×
819

820
            # Return 400 on critical exceptions
821
            if sms_exception.critical:
×
822
                raise exceptions.ValidationError(
×
823
                    sms_exception.detail
824
                ) from sms_exception
825
        return response.Response(
×
826
            status=200,
827
            template_name="twiml_empty_response.xml",
828
        )
829

830
    number_disabled = _check_disabled(relay_number, "texts")
×
831
    if number_disabled:
×
832
        return response.Response(status=200)
×
833

834
    inbound_contact = _get_inbound_contact(relay_number, from_num)
×
835
    if inbound_contact:
×
836
        _check_and_update_contact(inbound_contact, "texts", relay_number)
×
837

838
    text = message_body(inbound_from, inbound_body)
×
839
    send_iq_sms(real_phone.number, relay_number.number, text)
×
840

841
    relay_number.remaining_texts -= 1
×
842
    relay_number.texts_forwarded += 1
×
843
    relay_number.save()
×
844
    return response.Response(status=200)
×
845

846

847
@extend_schema(
1✔
848
    tags=["phones: Twilio"],
849
    parameters=[
850
        OpenApiParameter(name="X-Twilio-Signature", required=True, location="header"),
851
    ],
852
    request=OpenApiRequest(
853
        TwilioInboundCallSerializer,
854
        examples=[
855
            OpenApiExample(
856
                "request",
857
                {"Caller": "+13035556789", "Called": "+14045556789"},
858
            )
859
        ],
860
    ),
861
    responses={
862
        "200": OpenApiResponse(
863
            {
864
                "type": "object",
865
                "xml": {"name": "Response"},
866
                "properties": {"say": {"type": "string"}},
867
            },
868
            description="The number is disabled.",
869
            examples=[
870
                OpenApiExample(
871
                    "disabled", {"say": "Sorry, that number is not available."}
872
                )
873
            ],
874
        ),
875
        "201": OpenApiResponse(
876
            {
877
                "type": "object",
878
                "xml": {"name": "Response"},
879
                "properties": {
880
                    "Dial": {
881
                        "type": "object",
882
                        "properties": {
883
                            "callerId": {
884
                                "type": "string",
885
                                "xml": {"attribute": "true"},
886
                            },
887
                            "Number": {"type": "string"},
888
                        },
889
                    }
890
                },
891
            },
892
            description="Connect the caller to the Relay user.",
893
            examples=[
894
                OpenApiExample(
895
                    "success",
896
                    {"Dial": {"callerId": "+13035556789", "Number": "+15025558642"}},
897
                )
898
            ],
899
        ),
900
        "400": OpenApiResponse(
901
            {"type": "object", "xml": {"name": "Error"}},
902
            description="Unable to complete request.",
903
            examples=[
904
                OpenApiExample(
905
                    "invalid signature",
906
                    {
907
                        "status_code": 400,
908
                        "code": "invalid",
909
                        "title": "Invalid Request: Invalid Signature",
910
                    },
911
                ),
912
                OpenApiExample(
913
                    "out of call time for month",
914
                    {
915
                        "status_code": 400,
916
                        "code": "invalid",
917
                        "title": "Number Is Out Of Seconds.",
918
                    },
919
                ),
920
            ],
921
        ),
922
    },
923
)
924
@decorators.api_view(["POST"])
1✔
925
@decorators.permission_classes([permissions.AllowAny])
1✔
926
@decorators.renderer_classes([TemplateTwiMLRenderer])
1✔
927
def inbound_call(request):
1✔
928
    """
929
    Handle an inbound call request sent by Twilio.
930

931
    The return value is TwilML Response XML that reports the error or instructs
932
    Twilio to connect the callers.
933
    """
934
    incr_if_enabled("phones_inbound_call")
1✔
935
    _validate_twilio_request(request)
1✔
936
    inbound_from = request.data.get("Caller", None)
1✔
937
    inbound_to = request.data.get("Called", None)
1✔
938
    if inbound_from is None or inbound_to is None:
1✔
939
        raise exceptions.ValidationError("Call data missing Caller or Called.")
1✔
940

941
    relay_number, real_phone = _get_phone_objects(inbound_to)
1✔
942

943
    number_disabled = _check_disabled(relay_number, "calls")
1✔
944
    if number_disabled:
1✔
945
        say = "Sorry, that number is not available."
1✔
946
        return response.Response(
1✔
947
            {"say": say}, status=200, template_name="twiml_blocked.xml"
948
        )
949

950
    _check_remaining(relay_number, "seconds")
1✔
951

952
    inbound_contact = _get_inbound_contact(relay_number, inbound_from)
1✔
953
    if inbound_contact:
1!
954
        _check_and_update_contact(inbound_contact, "calls", relay_number)
1✔
955

956
    relay_number.calls_forwarded += 1
1✔
957
    relay_number.save()
1✔
958

959
    # Note: TemplateTwiMLRenderer will render this as TwiML
960
    incr_if_enabled("phones_outbound_call")
1✔
961
    return response.Response(
1✔
962
        {"inbound_from": inbound_from, "real_number": real_phone.number},
963
        status=201,
964
        template_name="twiml_dial.xml",
965
    )
966

967

968
@extend_schema(
1✔
969
    tags=["phones: Twilio"],
970
    request=OpenApiRequest(
971
        TwilioVoiceStatusSerializer,
972
        examples=[
973
            OpenApiExample(
974
                "Call is complete",
975
                {
976
                    "CallSid": "CA" + "x" * 32,
977
                    "Called": "+14045556789",
978
                    "CallStatus": "completed",
979
                    "CallDuration": 127,
980
                },
981
            )
982
        ],
983
    ),
984
    parameters=[
985
        OpenApiParameter(name="X-Twilio-Signature", required=True, location="header"),
986
    ],
987
    responses={
988
        "200": OpenApiResponse(description="Call status was processed."),
989
        "400": OpenApiResponse(
990
            description="Required parameters are incorrect or missing."
991
        ),
992
    },
993
)
994
@decorators.api_view(["POST"])
1✔
995
@decorators.permission_classes([permissions.AllowAny])
1✔
996
def voice_status(request):
1✔
997
    """
998
    Twilio callback for voice call status.
999

1000
    When the call is complete, the user's remaining monthly time is updated, and
1001
    the call is deleted from Twilio logs.
1002
    """
1003
    incr_if_enabled("phones_voice_status")
1✔
1004
    _validate_twilio_request(request)
1✔
1005
    call_sid = request.data.get("CallSid", None)
1✔
1006
    called = request.data.get("Called", None)
1✔
1007
    call_status = request.data.get("CallStatus", None)
1✔
1008
    if call_sid is None or called is None or call_status is None:
1✔
1009
        raise exceptions.ValidationError("Call data missing Called, CallStatus")
1✔
1010
    if call_status != "completed":
1✔
1011
        return response.Response(status=200)
1✔
1012
    call_duration = request.data.get("CallDuration", None)
1✔
1013
    if call_duration is None:
1✔
1014
        raise exceptions.ValidationError("completed call data missing CallDuration")
1✔
1015
    relay_number, _ = _get_phone_objects(called)
1✔
1016
    relay_number.remaining_seconds = relay_number.remaining_seconds - int(call_duration)
1✔
1017
    relay_number.save()
1✔
1018
    if relay_number.remaining_seconds < 0:
1✔
1019
        info_logger.info(
1✔
1020
            "phone_limit_exceeded",
1021
            extra={
1022
                "fxa_uid": relay_number.user.profile.fxa.uid,
1023
                "call_duration_in_seconds": int(call_duration),
1024
                "relay_number_enabled": relay_number.enabled,
1025
                "remaining_seconds": relay_number.remaining_seconds,
1026
                "remaining_minutes": relay_number.remaining_minutes,
1027
            },
1028
        )
1029
    client = twilio_client()
1✔
1030
    client.calls(call_sid).delete()
1✔
1031
    return response.Response(status=200)
1✔
1032

1033

1034
@extend_schema(
1✔
1035
    tags=["phones: Twilio"],
1036
    request=OpenApiRequest(
1037
        TwilioSmsStatusSerializer,
1038
        examples=[
1039
            OpenApiExample(
1040
                "SMS is delivered",
1041
                {"SmsStatus": "delivered", "MessageSid": "SM" + "x" * 32},
1042
            )
1043
        ],
1044
    ),
1045
    parameters=[
1046
        OpenApiParameter(name="X-Twilio-Signature", required=True, location="header"),
1047
    ],
1048
    responses={
1049
        "200": OpenApiResponse(description="SMS status was processed."),
1050
        "400": OpenApiResponse(
1051
            description="Required parameters are incorrect or missing."
1052
        ),
1053
    },
1054
)
1055
@decorators.api_view(["POST"])
1✔
1056
@decorators.permission_classes([permissions.AllowAny])
1✔
1057
def sms_status(request):
1✔
1058
    """
1059
    Twilio callback for SMS status.
1060

1061
    When the message is delivered, this calls Twilio to delete the message from logs.
1062
    """
1063
    _validate_twilio_request(request)
1✔
1064
    sms_status = request.data.get("SmsStatus", None)
1✔
1065
    message_sid = request.data.get("MessageSid", None)
1✔
1066
    if sms_status is None or message_sid is None:
1✔
1067
        raise exceptions.ValidationError(
1✔
1068
            "Text status data missing SmsStatus or MessageSid"
1069
        )
1070
    if sms_status != "delivered":
1✔
1071
        return response.Response(status=200)
1✔
1072
    client = twilio_client()
1✔
1073
    message = client.messages(message_sid)
1✔
1074
    _try_delete_from_twilio(message)
1✔
1075
    return response.Response(status=200)
1✔
1076

1077

1078
@decorators.permission_classes([permissions.IsAuthenticated, HasPhoneService])
1✔
1079
@extend_schema(
1✔
1080
    tags=["phones: Outbound"],
1081
    request=OpenApiRequest(
1082
        OutboundCallSerializer,
1083
        examples=[OpenApiExample("request", {"to": "+13035556789"})],
1084
    ),
1085
    responses={
1086
        200: OpenApiResponse(description="Call initiated."),
1087
        400: OpenApiResponse(
1088
            description="Input error, or user does not have a Relay phone."
1089
        ),
1090
        401: OpenApiResponse(description="Authentication required."),
1091
        403: OpenApiResponse(
1092
            description="User does not have 'outbound_phone' waffle flag."
1093
        ),
1094
    },
1095
)
1096
@decorators.api_view(["POST"])
1✔
1097
def outbound_call(request):
1✔
1098
    """Make a call from the authenticated user's relay number."""
1099
    # TODO: Create or update an OutboundContact (new model) on send, or limit
1100
    # to InboundContacts.
1101
    if not flag_is_active(request, "outbound_phone"):
1✔
1102
        # Return Permission Denied error
1103
        return response.Response(
1✔
1104
            {"detail": "Requires outbound_phone waffle flag."}, status=403
1105
        )
1106
    try:
1✔
1107
        real_phone = RealPhone.objects.get(user=request.user, verified=True)
1✔
1108
    except RealPhone.DoesNotExist:
1✔
1109
        return response.Response(
1✔
1110
            {"detail": "Requires a verified real phone and phone mask."}, status=400
1111
        )
1112
    try:
1✔
1113
        relay_number = RelayNumber.objects.get(user=request.user)
1✔
1114
    except RelayNumber.DoesNotExist:
1✔
1115
        return response.Response({"detail": "Requires a phone mask."}, status=400)
1✔
1116

1117
    client = twilio_client()
1✔
1118

1119
    to = _validate_number(request, "to")  # Raises ValidationError on invalid number
1✔
1120
    client.calls.create(
1✔
1121
        twiml=(
1122
            f"<Response><Say>Dialing {to.national_format} ...</Say>"
1123
            f"<Dial>{to.phone_number}</Dial></Response>"
1124
        ),
1125
        to=real_phone.number,
1126
        from_=relay_number.number,
1127
    )
1128
    return response.Response(status=200)
1✔
1129

1130

1131
@decorators.permission_classes([permissions.IsAuthenticated, HasPhoneService])
1✔
1132
@extend_schema(
1✔
1133
    tags=["phones: Outbound"],
1134
    request=OpenApiRequest(
1135
        OutboundSmsSerializer,
1136
        examples=[
1137
            OpenApiExample("request", {"body": "Hello!", "destination": "+13045554567"})
1138
        ],
1139
    ),
1140
    responses={
1141
        200: OpenApiResponse(description="Message sent."),
1142
        400: OpenApiResponse(
1143
            description="Input error, or user does not have a Relay phone."
1144
        ),
1145
        401: OpenApiResponse(description="Authentication required."),
1146
        403: OpenApiResponse(
1147
            description="User does not have 'outbound_phone' waffle flag."
1148
        ),
1149
    },
1150
)
1151
@decorators.api_view(["POST"])
1✔
1152
def outbound_sms(request):
1✔
1153
    """
1154
    Send a message from the user's relay number.
1155

1156
    POST params:
1157
        body: the body of the message
1158
        destination: E.164-formatted phone number
1159

1160
    """
1161
    # TODO: Create or update an OutboundContact (new model) on send, or limit
1162
    # to InboundContacts.
1163
    # TODO: Reduce user's SMS messages for the month by one
1164
    if not flag_is_active(request, "outbound_phone"):
1✔
1165
        return response.Response(
1✔
1166
            {"detail": "Requires outbound_phone waffle flag."}, status=403
1167
        )
1168
    try:
1✔
1169
        relay_number = RelayNumber.objects.get(user=request.user)
1✔
1170
    except RelayNumber.DoesNotExist:
1✔
1171
        return response.Response({"detail": "Requires a phone mask."}, status=400)
1✔
1172

1173
    errors = {}
1✔
1174
    body = request.data.get("body")
1✔
1175
    if not body:
1✔
1176
        errors["body"] = "A message body is required."
1✔
1177
    destination_number = request.data.get("destination")
1✔
1178
    if not destination_number:
1✔
1179
        errors["destination"] = "A destination number is required."
1✔
1180
    if errors:
1✔
1181
        return response.Response(errors, status=400)
1✔
1182

1183
    # Raises ValidationError on invalid number
1184
    to = _validate_number(request, "destination")
1✔
1185

1186
    client = twilio_client()
1✔
1187
    client.messages.create(from_=relay_number.number, body=body, to=to.phone_number)
1✔
1188
    return response.Response(status=200)
1✔
1189

1190

1191
@decorators.permission_classes([permissions.IsAuthenticated, HasPhoneService])
1✔
1192
@extend_schema(
1✔
1193
    tags=["phones: Outbound"],
1194
    parameters=[
1195
        OpenApiParameter(
1196
            name="with",
1197
            description="filter to messages with the given E.164 number",
1198
        ),
1199
        OpenApiParameter(
1200
            name="direction",
1201
            enum=["inbound", "outbound"],
1202
            description="filter to inbound or outbound messages",
1203
        ),
1204
    ],
1205
    responses={
1206
        "200": OpenApiResponse(
1207
            TwilioMessagesSerializer(many=True),
1208
            description="A list of the user's SMS messages.",
1209
            examples=[
1210
                OpenApiExample(
1211
                    "success",
1212
                    {
1213
                        "to": "+13035556789",
1214
                        "date_sent": datetime.now(UTC).isoformat(),
1215
                        "body": "Hello!",
1216
                        "from": "+14045556789",
1217
                    },
1218
                )
1219
            ],
1220
        ),
1221
        "400": OpenApiResponse(description="Unable to complete request."),
1222
        "403": OpenApiResponse(
1223
            description="Caller does not have 'outbound_phone' waffle flag."
1224
        ),
1225
    },
1226
)
1227
@decorators.api_view(["GET"])
1✔
1228
def list_messages(request):
1✔
1229
    """
1230
    Get the user's SMS messages sent to or from the phone mask
1231

1232
    Pass ?with=<E.164> parameter to filter the messages to only the ones sent between
1233
    the phone mask and the <E.164> number.
1234

1235
    Pass ?direction=inbound|outbound to filter the messages to only the inbound or
1236
    outbound messages. If omitted, return both.
1237
    """
1238
    # TODO: Support filtering to messages for outbound-only phones.
1239
    # TODO: Show data from our own (encrypted) store, rather than from Twilio's
1240

1241
    if not flag_is_active(request, "outbound_phone"):
1✔
1242
        return response.Response(
1✔
1243
            {"detail": "Requires outbound_phone waffle flag."}, status=403
1244
        )
1245
    try:
1✔
1246
        relay_number = RelayNumber.objects.get(user=request.user)
1✔
1247
    except RelayNumber.DoesNotExist:
1✔
1248
        return response.Response({"detail": "Requires a phone mask."}, status=400)
1✔
1249

1250
    _with = request.query_params.get("with", None)
1✔
1251
    _direction = request.query_params.get("direction", None)
1✔
1252
    if _direction and _direction not in ("inbound", "outbound"):
1✔
1253
        return response.Response(
1✔
1254
            {"direction": "Invalid value, valid values are 'inbound' or 'outbound'"},
1255
            status=400,
1256
        )
1257

1258
    contact = None
1✔
1259
    if _with:
1✔
1260
        try:
1✔
1261
            contact = InboundContact.objects.get(
1✔
1262
                relay_number=relay_number, inbound_number=_with
1263
            )
1264
        except InboundContact.DoesNotExist:
1✔
1265
            return response.Response(
1✔
1266
                {"with": "No inbound contacts matching the number"}, status=400
1267
            )
1268

1269
    data = {}
1✔
1270
    client = twilio_client()
1✔
1271
    if not _direction or _direction == "inbound":
1✔
1272
        # Query Twilio for SMS messages to the user's phone mask
1273
        params = {"to": relay_number.number}
1✔
1274
        if contact:
1✔
1275
            # Filter query to SMS from this contact to the phone mask
1276
            params["from_"] = contact.inbound_number
1✔
1277
        data["inbound_messages"] = convert_twilio_messages_to_dict(
1✔
1278
            client.messages.list(**params)
1279
        )
1280
    if not _direction or _direction == "outbound":
1✔
1281
        # Query Twilio for SMS messages from the user's phone mask
1282
        params = {"from_": relay_number.number}
1✔
1283
        if contact:
1✔
1284
            # Filter query to SMS from the phone mask to this contact
1285
            params["to"] = contact.inbound_number
1✔
1286
        data["outbound_messages"] = convert_twilio_messages_to_dict(
1✔
1287
            client.messages.list(**params)
1288
        )
1289
    return response.Response(data, status=200)
1✔
1290

1291

1292
def _get_phone_objects(inbound_to):
1✔
1293
    # Get RelayNumber and RealPhone
1294
    try:
1✔
1295
        relay_number = RelayNumber.objects.get(number=inbound_to)
1✔
1296
        real_phone = RealPhone.objects.get(user=relay_number.user, verified=True)
1✔
1297
    except ObjectDoesNotExist:
1✔
1298
        raise exceptions.ValidationError("Could not find relay number.")
1✔
1299

1300
    return relay_number, real_phone
1✔
1301

1302

1303
class RelaySMSException(Exception):
1✔
1304
    """
1305
    Base class for exceptions when handling SMS messages.
1306

1307
    Modeled after restframework.APIException, but without a status_code.
1308

1309
    TODO MPP-3722: Refactor to a common base class with api.exceptions.RelayAPIException
1310
    """
1311

1312
    critical: bool
1✔
1313
    default_code: str
1✔
1314
    default_detail: str | None = None
1✔
1315
    default_detail_template: str | None = None
1✔
1316

1317
    def __init__(self, critical=False, *args, **kwargs):
1✔
1318
        self.critical = critical
1✔
1319
        if not (
1!
1320
            self.default_detail is not None and self.default_detail_template is None
1321
        ) and not (
1322
            self.default_detail is None and self.default_detail_template is not None
1323
        ):
NEW
1324
            raise ValueError(
×
1325
                "One and only one of default_detail or "
1326
                "default_detail_template must be None."
1327
            )
1328
        super().__init__(*args, **kwargs)
1✔
1329

1330
    @property
1✔
1331
    def detail(self):
1✔
1332
        if self.default_detail:
1✔
1333
            return self.default_detail
1✔
1334
        else:
1335
            if self.default_detail_template is None:
1!
NEW
1336
                raise ValueError("self.default_detail_template must not be None.")
×
1337
            return self.default_detail_template.format(**self.error_context())
1✔
1338

1339
    def get_codes(self):
1✔
1340
        return self.default_code
1✔
1341

1342
    def error_context(self) -> ErrorContextType:
1✔
1343
        """Return context variables for client-side translation."""
1344
        return {}
1✔
1345

1346

1347
class NoPhoneLog(RelaySMSException):
1✔
1348
    default_code = "no_phone_log"
1✔
1349
    default_detail_template = (
1✔
1350
        "To reply, you must allow Firefox Relay to keep a log of your callers"
1351
        " and text senders. You can update this under “Caller and texts log” here:"
1352
        "{account_settings_url}."
1353
    )
1354

1355
    def error_context(self) -> ErrorContextType:
1✔
1356
        return {
1✔
1357
            "account_settings_url": f"{settings.SITE_ORIGIN or ''}/accounts/settings/"
1358
        }
1359

1360

1361
class NoPreviousSender(RelaySMSException):
1✔
1362
    default_code = "no_previous_sender"
1✔
1363
    default_detail = (
1✔
1364
        "Message failed to send. You can only reply to phone numbers that have sent"
1365
        " you a text message."
1366
    )
1367

1368

1369
class ShortPrefixException(RelaySMSException):
1✔
1370
    """Base exception for short prefix exceptions"""
1371

1372
    def __init__(self, short_prefix: str):
1✔
1373
        self.short_prefix = short_prefix
1✔
1374
        super().__init__()
1✔
1375

1376
    def error_context(self) -> ErrorContextType:
1✔
1377
        return {"short_prefix": self.short_prefix}
1✔
1378

1379

1380
class FullNumberException(RelaySMSException):
1✔
1381
    """Base exception for full number exceptions"""
1382

1383
    def __init__(self, full_number: str):
1✔
1384
        self.full_number = full_number
1✔
1385
        super().__init__()
1✔
1386

1387
    def error_context(self) -> ErrorContextType:
1✔
1388
        return {"full_number": self.full_number}
1✔
1389

1390

1391
class ShortPrefixMatchesNoSenders(ShortPrefixException):
1✔
1392
    default_code = "short_prefix_matches_no_senders"
1✔
1393
    default_detail_template = (
1✔
1394
        "Message failed to send. There is no phone number in this thread ending"
1395
        " in {short_prefix}. Please check the number and try again."
1396
    )
1397

1398

1399
class FullNumberMatchesNoSenders(FullNumberException):
1✔
1400
    default_code = "full_number_matches_no_senders"
1✔
1401
    default_detail_template = (
1✔
1402
        "Message failed to send. There is no previous sender with the phone"
1403
        " number {full_number}. Please check the number and try again."
1404
    )
1405

1406

1407
class MultipleNumberMatches(ShortPrefixException):
1✔
1408
    default_code = "multiple_number_matches"
1✔
1409
    default_detail_template = (
1✔
1410
        "Message failed to send. There is more than one phone number in this"
1411
        " thread ending in {short_prefix}. To retry, start your message with"
1412
        " the complete number."
1413
    )
1414

1415

1416
class NoBodyAfterShortPrefix(ShortPrefixException):
1✔
1417
    default_code = "no_body_after_short_prefix"
1✔
1418
    default_detail_template = (
1✔
1419
        "Message failed to send. Please include a message after the sender identifier"
1420
        " {short_prefix}."
1421
    )
1422

1423

1424
class NoBodyAfterFullNumber(FullNumberException):
1✔
1425
    default_code = "no_body_after_full_number"
1✔
1426
    default_detail_template = (
1✔
1427
        "Message failed to send. Please include a message after the phone number"
1428
        " {full_number}."
1429
    )
1430

1431

1432
def _prepare_sms_reply(
1✔
1433
    relay_number: RelayNumber, inbound_body: str
1434
) -> tuple[RelayNumber, str, str]:
1435
    incr_if_enabled("phones_handle_sms_reply")
1✔
1436
    if not relay_number.storing_phone_log:
1✔
1437
        # We do not store user's contacts in our database
1438
        raise NoPhoneLog(critical=True)
1✔
1439

1440
    match = _match_senders_by_prefix(relay_number, inbound_body)
1✔
1441

1442
    # Fail if prefix match is ambiguous
1443
    if match and not match.contacts and match.match_type == "short":
1✔
1444
        raise ShortPrefixMatchesNoSenders(short_prefix=match.detected)
1✔
1445
    if match and not match.contacts and match.match_type == "full":
1✔
1446
        raise FullNumberMatchesNoSenders(full_number=match.detected)
1✔
1447
    if match and len(match.contacts) > 1:
1✔
1448
        if not match.match_type == "short":
1!
NEW
1449
            raise ValueError("match.match_type must be 'short'.")
×
1450
        raise MultipleNumberMatches(short_prefix=match.detected)
1✔
1451

1452
    # Determine the destination number
1453
    destination_number: str | None = None
1✔
1454
    if match:
1✔
1455
        # Use the sender matched by the prefix
1456
        if not len(match.contacts) == 1:
1!
NEW
1457
            raise ValueError("len(match.contacts) must be 1.")
×
1458
        destination_number = match.contacts[0].inbound_number
1✔
1459
    else:
1460
        # No prefix, default to last sender if any
1461
        last_sender = get_last_text_sender(relay_number)
1✔
1462
        destination_number = getattr(last_sender, "inbound_number", None)
1✔
1463

1464
    # Fail if no last sender
1465
    if destination_number is None:
1✔
1466
        raise NoPreviousSender(critical=True)
1✔
1467

1468
    # Determine the message body
1469
    if match:
1✔
1470
        body = inbound_body.removeprefix(match.prefix)
1✔
1471
    else:
1472
        body = inbound_body
1✔
1473

1474
    # Fail if the prefix matches a sender, but there is no body to send
1475
    if match and not body and match.match_type == "short":
1✔
1476
        raise NoBodyAfterShortPrefix(short_prefix=match.detected)
1✔
1477
    if match and not body and match.match_type == "full":
1✔
1478
        raise NoBodyAfterFullNumber(full_number=match.detected)
1✔
1479

1480
    return (relay_number, destination_number, body)
1✔
1481

1482

1483
@dataclass
1✔
1484
class MatchByPrefix:
1✔
1485
    """Details of parsing a text message for a prefix."""
1486

1487
    # Was it matched by short code or full number?
1488
    match_type: Literal["short", "full"]
1✔
1489
    # The prefix portion of the text message
1490
    prefix: str
1✔
1491
    # The detected short code or full number
1492
    detected: str
1✔
1493
    # The matching numbers, as e.164 strings, empty if None
1494
    numbers: list[str] = field(default_factory=list)
1✔
1495

1496

1497
@dataclass
1✔
1498
class MatchData(MatchByPrefix):
1✔
1499
    """Details of expanding a MatchByPrefix with InboundContacts."""
1500

1501
    # The matching InboundContacts
1502
    contacts: list[InboundContact] = field(default_factory=list)
1✔
1503

1504

1505
def _match_senders_by_prefix(relay_number: RelayNumber, text: str) -> MatchData | None:
1✔
1506
    """
1507
    Match a prefix to previous InboundContact(s).
1508

1509
    If no prefix was found, returns None
1510
    If a prefix was found, a MatchData object has details and matching InboundContacts
1511
    """
1512
    multi_replies_flag, _ = get_waffle_flag_model().objects.get_or_create(
1✔
1513
        name="multi_replies",
1514
        defaults={
1515
            "note": (
1516
                "MPP-2252: Use prefix on SMS text to specify the recipient,"
1517
                " rather than default of last contact."
1518
            )
1519
        },
1520
    )
1521

1522
    if (
1✔
1523
        multi_replies_flag.is_active_for_user(relay_number.user)
1524
        or multi_replies_flag.everyone
1525
    ):
1526
        # Load all the previous contacts, collect possible countries
1527
        contacts = InboundContact.objects.filter(relay_number=relay_number).all()
1✔
1528
        contacts_by_number: dict[str, InboundContact] = {}
1✔
1529
        for contact in contacts:
1✔
1530
            # TODO: don't default to US when we support other regions
1531
            pn = phonenumbers.parse(contact.inbound_number, "US")
1✔
1532
            e164 = phonenumbers.format_number(pn, phonenumbers.PhoneNumberFormat.E164)
1✔
1533
            if e164 not in contacts_by_number:
1!
1534
                contacts_by_number[e164] = contact
1✔
1535

1536
        match = _match_by_prefix(text, set(contacts_by_number.keys()))
1✔
1537
        if match:
1✔
1538
            return MatchData(
1✔
1539
                contacts=[contacts_by_number[num] for num in match.numbers],
1540
                **asdict(match),
1541
            )
1542
    return None
1✔
1543

1544

1545
_SMS_SHORT_PREFIX_RE = re.compile(
1✔
1546
    r"""
1547
^               # Start of string
1548
\s*             # One or more spaces
1549
\d{4}           # 4 digits
1550
\s*             # Optional whitespace
1551
[:]?     # At most one separator, sync with SMS_SEPARATORS below
1552
\s*             # Trailing whitespace
1553
""",
1554
    re.VERBOSE | re.ASCII,
1555
)
1556
_SMS_SEPARATORS = set(":")  # Sync with SMS_SHORT_PREFIX_RE above
1✔
1557

1558

1559
def _match_by_prefix(text: str, candidate_numbers: set[str]) -> MatchByPrefix | None:
1✔
1560
    """
1561
    Look for a prefix in a text message matching a set of candidate numbers.
1562

1563
    Arguments:
1564
    * A SMS text message
1565
    * A set of phone numbers in E.164 format
1566

1567
    Return None if no prefix was found, or MatchByPrefix with likely match(es)
1568
    """
1569
    # Gather potential region codes, needed by PhoneNumberMatcher
1570
    region_codes = set()
1✔
1571
    for candidate_number in candidate_numbers:
1✔
1572
        pn = phonenumbers.parse(candidate_number)
1✔
1573
        if pn.country_code:
1!
1574
            region_codes |= set(
1✔
1575
                phonenumbers.region_codes_for_country_code(pn.country_code)
1576
            )
1577

1578
    # Determine where the message may start
1579
    #  PhoneNumberMatcher doesn't work well with a number directly followed by text,
1580
    #  so just feed it the start of the message that _may_ be a number.
1581
    msg_start = 0
1✔
1582
    phone_characters = set(string.digits + string.punctuation + string.whitespace)
1✔
1583
    while msg_start < len(text) and text[msg_start] in phone_characters:
1✔
1584
        msg_start += 1
1✔
1585

1586
    # Does PhoneNumberMatcher detect a full number at start of message?
1587
    text_to_match = text[:msg_start]
1✔
1588
    for region_code in region_codes:
1✔
1589
        for match in phonenumbers.PhoneNumberMatcher(text_to_match, region_code):
1✔
1590
            e164 = phonenumbers.format_number(
1✔
1591
                match.number, phonenumbers.PhoneNumberFormat.E164
1592
            )
1593

1594
            # Look for end of prefix
1595
            end = match.start + len(match.raw_string)
1✔
1596
            found_one_sep = False
1✔
1597
            while True:
1✔
1598
                if end >= len(text):
1✔
1599
                    break
1✔
1600
                elif text[end].isspace():
1✔
1601
                    end += 1
1✔
1602
                elif text[end] in _SMS_SEPARATORS and not found_one_sep:
1✔
1603
                    found_one_sep = True
1✔
1604
                    end += 1
1✔
1605
                else:
1606
                    break
1✔
1607

1608
            prefix = text[:end]
1✔
1609
            if e164 in candidate_numbers:
1✔
1610
                numbers = [e164]
1✔
1611
            else:
1612
                numbers = []
1✔
1613
            return MatchByPrefix(
1✔
1614
                match_type="full", prefix=prefix, detected=e164, numbers=numbers
1615
            )
1616

1617
    # Is there a short prefix? Return all contacts whose last 4 digits match.
1618
    text_prefix_match = _SMS_SHORT_PREFIX_RE.match(text)
1✔
1619
    if text_prefix_match:
1✔
1620
        text_prefix = text_prefix_match.group(0)
1✔
1621
        digits = set(string.digits)
1✔
1622
        digit_suffix = "".join(digit for digit in text_prefix if digit in digits)
1✔
1623
        numbers = [e164 for e164 in candidate_numbers if e164[-4:] == digit_suffix]
1✔
1624
        return MatchByPrefix(
1✔
1625
            match_type="short",
1626
            prefix=text_prefix,
1627
            detected=digit_suffix,
1628
            numbers=sorted(numbers),
1629
        )
1630

1631
    # No prefix detected
1632
    return None
1✔
1633

1634

1635
def _check_disabled(relay_number, contact_type):
1✔
1636
    # Check if RelayNumber is disabled
1637
    if not relay_number.enabled:
1✔
1638
        attr = f"{contact_type}_blocked"
1✔
1639
        incr_if_enabled(f"phones_{contact_type}_global_blocked")
1✔
1640
        setattr(relay_number, attr, getattr(relay_number, attr) + 1)
1✔
1641
        relay_number.save()
1✔
1642
        return True
1✔
1643

1644

1645
def _check_remaining(relay_number, resource_type):
1✔
1646
    # Check the owner of the relay number (still) has phone service
1647
    if not relay_number.user.profile.has_phone:
1!
1648
        raise exceptions.ValidationError("Number owner does not have phone service")
×
1649
    model_attr = f"remaining_{resource_type}"
1✔
1650
    if getattr(relay_number, model_attr) <= 0:
1✔
1651
        incr_if_enabled(f"phones_out_of_{resource_type}")
1✔
1652
        raise exceptions.ValidationError(f"Number is out of {resource_type}.")
1✔
1653
    return True
1✔
1654

1655

1656
def _get_inbound_contact(relay_number, inbound_from):
1✔
1657
    # Check if RelayNumber is storing phone log
1658
    if not relay_number.storing_phone_log:
1✔
1659
        return None
1✔
1660

1661
    # Check if RelayNumber is blocking this inbound_from
1662
    inbound_contact, _ = InboundContact.objects.get_or_create(
1✔
1663
        relay_number=relay_number, inbound_number=inbound_from
1664
    )
1665
    return inbound_contact
1✔
1666

1667

1668
def _check_and_update_contact(inbound_contact, contact_type, relay_number):
1✔
1669
    if inbound_contact.blocked:
1✔
1670
        incr_if_enabled(f"phones_{contact_type}_specific_blocked")
1✔
1671
        contact_attr = f"num_{contact_type}_blocked"
1✔
1672
        setattr(
1✔
1673
            inbound_contact, contact_attr, getattr(inbound_contact, contact_attr) + 1
1674
        )
1675
        inbound_contact.save()
1✔
1676
        relay_attr = f"{contact_type}_blocked"
1✔
1677
        setattr(relay_number, relay_attr, getattr(relay_number, relay_attr) + 1)
1✔
1678
        relay_number.save()
1✔
1679
        raise exceptions.ValidationError(f"Number is not accepting {contact_type}.")
1✔
1680

1681
    inbound_contact.last_inbound_date = datetime.now(UTC)
1✔
1682
    singular_contact_type = contact_type[:-1]  # strip trailing "s"
1✔
1683
    inbound_contact.last_inbound_type = singular_contact_type
1✔
1684
    attr = f"num_{contact_type}"
1✔
1685
    setattr(inbound_contact, attr, getattr(inbound_contact, attr) + 1)
1✔
1686
    last_date_attr = f"last_{singular_contact_type}_date"
1✔
1687
    setattr(inbound_contact, last_date_attr, inbound_contact.last_inbound_date)
1✔
1688
    inbound_contact.save()
1✔
1689

1690

1691
def _validate_twilio_request(request):
1✔
1692
    if "X-Twilio-Signature" not in request._request.headers:
1✔
1693
        raise exceptions.ValidationError(
1✔
1694
            "Invalid request: missing X-Twilio-Signature header."
1695
        )
1696

1697
    url = request._request.build_absolute_uri()
1✔
1698
    sorted_params = {}
1✔
1699
    for param_key in sorted(request.data):
1✔
1700
        sorted_params[param_key] = request.data.get(param_key)
1✔
1701
    request_signature = request._request.headers["X-Twilio-Signature"]
1✔
1702
    validator = twilio_validator()
1✔
1703
    if not validator.validate(url, sorted_params, request_signature):
1✔
1704
        incr_if_enabled("phones_invalid_twilio_signature")
1✔
1705
        raise exceptions.ValidationError("Invalid request: invalid signature")
1✔
1706

1707

1708
def compute_iq_mac(message_id: str) -> str:
1✔
1709
    iq_api_key = settings.IQ_INBOUND_API_KEY
×
1710
    # FIXME: switch to proper hmac when iQ is ready
1711
    # mac = hmac.new(
1712
    #     iq_api_key.encode(), msg=message_id.encode(), digestmod=hashlib.sha256
1713
    # )
1714
    combined = iq_api_key + message_id
×
1715
    return hashlib.sha256(combined.encode()).hexdigest()
×
1716

1717

1718
def _validate_iq_request(request: Request) -> None:
1✔
1719
    if "Verificationtoken" not in request._request.headers:
×
1720
        raise exceptions.AuthenticationFailed("missing Verificationtoken header.")
×
1721

1722
    if "MessageId" not in request._request.headers:
×
1723
        raise exceptions.AuthenticationFailed("missing MessageId header.")
×
1724

1725
    message_id = request._request.headers["Messageid"]
×
1726
    mac = compute_iq_mac(message_id)
×
1727

1728
    token = request._request.headers["verificationToken"]
×
1729

1730
    if mac != token:
×
1731
        raise exceptions.AuthenticationFailed("verficiationToken != computed sha256")
×
1732

1733

1734
def convert_twilio_messages_to_dict(twilio_messages):
1✔
1735
    """
1736
    To serialize twilio messages to JSON for the API,
1737
    we need to convert them into dictionaries.
1738
    """
1739
    messages_as_dicts = []
1✔
1740
    for twilio_message in twilio_messages:
1✔
1741
        message = {}
1✔
1742
        message["from"] = twilio_message.from_
1✔
1743
        message["to"] = twilio_message.to
1✔
1744
        message["date_sent"] = twilio_message.date_sent
1✔
1745
        message["body"] = twilio_message.body
1✔
1746
        messages_as_dicts.append(message)
1✔
1747
    return messages_as_dicts
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