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

mozilla / fx-private-relay / 81342c7f-7ba3-4126-b81b-c0fca5ec75f5

17 Sep 2024 01:03PM CUT coverage: 85.556% (+0.05%) from 85.502%
81342c7f-7ba3-4126-b81b-c0fca5ec75f5

push

circleci

web-flow
Merge pull request #5044 from mozilla/two-phones-mpp-3897

MPP-3897: Add managers to RealPhone to handle multiple phones per user

4121 of 5274 branches covered (78.14%)

Branch coverage included in aggregate %.

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

1 existing line in 1 file now uncovered.

16208 of 18487 relevant lines covered (87.67%)

10.3 hits per line

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

90.4
/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.exceptions import (
1✔
43
    FullNumberMatchesNoSenders,
44
    MultipleNumberMatches,
45
    NoBodyAfterFullNumber,
46
    NoBodyAfterShortPrefix,
47
    NoPhoneLog,
48
    NoPreviousSender,
49
    RelaySMSException,
50
    ShortPrefixMatchesNoSenders,
51
)
52
from phones.iq_utils import send_iq_sms
1✔
53
from phones.models import (
1✔
54
    DEFAULT_REGION,
55
    InboundContact,
56
    RealPhone,
57
    RelayNumber,
58
    area_code_numbers,
59
    get_last_text_sender,
60
    location_numbers,
61
    send_welcome_message,
62
    suggested_numbers,
63
)
64
from privaterelay.ftl_bundles import main as ftl_bundle
1✔
65

66
from ..exceptions import ConflictError
1✔
67
from ..permissions import HasPhoneService
1✔
68
from ..renderers import TemplateTwiMLRenderer, vCardRenderer
1✔
69
from ..serializers.phones import (
1✔
70
    InboundContactSerializer,
71
    IqInboundSmsSerializer,
72
    OutboundCallSerializer,
73
    OutboundSmsSerializer,
74
    RealPhoneSerializer,
75
    RelayNumberSerializer,
76
    TwilioInboundCallSerializer,
77
    TwilioInboundSmsSerializer,
78
    TwilioMessagesSerializer,
79
    TwilioNumberSuggestion,
80
    TwilioNumberSuggestionGroups,
81
    TwilioSmsStatusSerializer,
82
    TwilioVoiceStatusSerializer,
83
)
84

85
logger = logging.getLogger("events")
1✔
86
info_logger = logging.getLogger("eventsinfo")
1✔
87

88

89
def twilio_validator():
1✔
90
    return phones_config().twilio_validator
1✔
91

92

93
def twiml_app():
1✔
94
    return phones_config().twiml_app
×
95

96

97
class RealPhoneRateThrottle(throttling.UserRateThrottle):
1✔
98
    rate = settings.PHONE_RATE_LIMIT
1✔
99

100

101
@extend_schema(tags=["phones"])
1✔
102
class RealPhoneViewSet(SaveToRequestUser, viewsets.ModelViewSet):
1✔
103
    """
104
    Get real phone number records for the authenticated user.
105

106
    The authenticated user must have a subscription that grants one of the
107
    `SUBSCRIPTIONS_WITH_PHONE` capabilities.
108

109
    Client must be authenticated, and these endpoints only return data that is
110
    "owned" by the authenticated user.
111

112
    All endpoints are rate-limited to settings.PHONE_RATE_LIMIT
113
    """
114

115
    http_method_names = ["get", "post", "patch", "delete"]
1✔
116
    permission_classes = [permissions.IsAuthenticated, HasPhoneService]
1✔
117
    serializer_class = RealPhoneSerializer
1✔
118
    # TODO: this doesn't seem to e working?
119
    throttle_classes = [RealPhoneRateThrottle]
1✔
120

121
    def get_queryset(self) -> QuerySet[RealPhone]:
1✔
122
        if isinstance(self.request.user, User):
1✔
123
            return RealPhone.objects.filter(user=self.request.user)
1✔
124
        return RealPhone.objects.none()
1✔
125

126
    def create(self, request):
1✔
127
        """
128
        Add real phone number to the authenticated user.
129

130
        The "flow" to verify a real phone number is:
131
        1. POST a number (Will text a verification code to the number)
132
        2a. PATCH the verification code to the realphone/{id} endpoint
133
        2b. POST the number and verification code together
134

135
        The authenticated user must have a subscription that grants one of the
136
        `SUBSCRIPTIONS_WITH_PHONE` capabilities.
137

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

143
        If the `POST` does NOT include a `verification_code` and the number is
144
        a valid (currently, US-based) number, this endpoint will text a
145
        verification code to the number.
146

147
        If the `POST` DOES include a `verification_code`, and the code matches
148
        a code already sent to the number, this endpoint will set `verified` to
149
        `True` for this number.
150

151
        [e164]: https://en.wikipedia.org/wiki/E.164
152
        """
153
        incr_if_enabled("phones_RealPhoneViewSet.create")
1✔
154
        serializer = self.get_serializer(data=request.data)
1✔
155
        serializer.is_valid(raise_exception=True)
1✔
156

157
        # Check if the request includes a valid verification_code
158
        # value, look for any un-expired record that matches both the phone
159
        # number and verification code and mark it verified.
160
        verification_code = serializer.validated_data.get("verification_code")
1✔
161
        if verification_code:
1✔
162
            try:
1✔
163
                valid_record = (
1✔
164
                    RealPhone.recent_objects.get_for_user_number_and_verification_code(
165
                        request.user,
166
                        serializer.validated_data["number"],
167
                        verification_code,
168
                    )
169
                )
170
            except RealPhone.DoesNotExist:
1✔
171
                incr_if_enabled("phones_RealPhoneViewSet.create.invalid_verification")
1✔
172
                raise exceptions.ValidationError(
1✔
173
                    "Could not find that verification_code for user and number."
174
                    " It may have expired."
175
                )
176

177
            headers = self.get_success_headers(serializer.validated_data)
1✔
178
            verified_valid_record = valid_record.mark_verified()
1✔
179
            incr_if_enabled("phones_RealPhoneViewSet.create.mark_verified")
1✔
180
            response_data = model_to_dict(
1✔
181
                verified_valid_record,
182
                fields=[
183
                    "id",
184
                    "number",
185
                    "verification_sent_date",
186
                    "verified",
187
                    "verified_date",
188
                ],
189
            )
190
            return response.Response(response_data, status=201, headers=headers)
1✔
191

192
        # to prevent sending verification codes to verified numbers,
193
        # check if the number is already a verified number.
194
        if RealPhone.verified_objects.exists_for_number(
1!
195
            serializer.validated_data["number"]
196
        ):
UNCOV
197
            raise ConflictError("A verified record already exists for this number.")
×
198

199
        # to prevent abusive sending of verification messages,
200
        # check if there is an un-expired verification code for number
201
        if RealPhone.pending_objects.exists_for_number(
1✔
202
            serializer.validated_data["number"]
203
        ):
204
            raise ConflictError(
1✔
205
                "An unverified record already exists for this number.",
206
            )
207

208
        # We call an additional _validate_number function with the request
209
        # to try to parse the number as a local national number in the
210
        # request.country attribute
211
        valid_number = _validate_number(request)
1✔
212
        serializer.validated_data["number"] = valid_number.phone_number
1✔
213
        serializer.validated_data["country_code"] = valid_number.country_code.upper()
1✔
214

215
        self.perform_create(serializer)
1✔
216
        incr_if_enabled("phones_RealPhoneViewSet.perform_create")
1✔
217
        headers = self.get_success_headers(serializer.validated_data)
1✔
218
        response_data = serializer.data
1✔
219
        response_data["message"] = (
1✔
220
            "Sent verification code to "
221
            f"{valid_number.phone_number} "
222
            f"(country: {valid_number.country_code} "
223
            f"carrier: {valid_number.carrier})"
224
        )
225
        return response.Response(response_data, status=201, headers=headers)
1✔
226

227
    # check verification_code during partial_update to compare
228
    # the value sent in the request against the value already on the instance
229
    # TODO: this logic might be able to move "up" into the model, but it will
230
    # need some more serious refactoring of the RealPhone.save() method
231
    def partial_update(self, request, *args, **kwargs):
1✔
232
        """
233
        Update the authenticated user's real phone number.
234

235
        The authenticated user must have a subscription that grants one of the
236
        `SUBSCRIPTIONS_WITH_PHONE` capabilities.
237

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

240
        The `number` field should be in [E.164][e164] format which includes a country
241
        code.
242

243
        The `verification_code` should be the code that was texted to the
244
        number during the `POST`. If it matches, this endpoint will set
245
        `verified` to `True` for this number.
246

247
        [e164]: https://en.wikipedia.org/wiki/E.164
248
        """
249
        incr_if_enabled("phones_RealPhoneViewSet.partial_update")
1✔
250
        instance = self.get_object()
1✔
251
        if request.data["number"] != instance.number:
1✔
252
            raise exceptions.ValidationError("Invalid number for ID.")
1✔
253
        # TODO: check verification_sent_date is not "expired"?
254
        # Note: the RealPhone.save() logic should prevent expired verifications
255
        if (
1✔
256
            "verification_code" not in request.data
257
            or not request.data["verification_code"] == instance.verification_code
258
        ):
259
            raise exceptions.ValidationError(
1✔
260
                "Invalid verification_code for ID. It may have expired."
261
            )
262

263
        instance.mark_verified()
1✔
264
        incr_if_enabled("phones_RealPhoneViewSet.partial_update.mark_verified")
1✔
265
        return super().partial_update(request, *args, **kwargs)
1✔
266

267
    def destroy(self, request, *args, **kwargs):
1✔
268
        """
269
        Delete a real phone resource.
270

271
        Only **un-verified** real phone resources can be deleted.
272
        """
273
        incr_if_enabled("phones_RealPhoneViewSet.destroy")
1✔
274
        instance = self.get_object()
1✔
275
        if instance.verified:
1✔
276
            raise exceptions.ValidationError(
1✔
277
                "Only un-verified real phone resources can be deleted."
278
            )
279

280
        return super().destroy(request, *args, **kwargs)
1✔
281

282

283
@extend_schema(tags=["phones"])
1✔
284
class RelayNumberViewSet(SaveToRequestUser, viewsets.ModelViewSet):
1✔
285
    http_method_names = ["get", "post", "patch"]
1✔
286
    permission_classes = [permissions.IsAuthenticated, HasPhoneService]
1✔
287
    serializer_class = RelayNumberSerializer
1✔
288

289
    def get_queryset(self) -> QuerySet[RelayNumber]:
1✔
290
        if isinstance(self.request.user, User):
1✔
291
            return RelayNumber.objects.filter(user=self.request.user)
1✔
292
        return RelayNumber.objects.none()
1✔
293

294
    def create(self, request, *args, **kwargs):
1✔
295
        """
296
        Provision a phone number with Twilio and assign to the authenticated user.
297

298
        ⚠️ **THIS WILL BUY A PHONE NUMBER** ⚠️
299
        If you have real account credentials in your `TWILIO_*` env vars, this
300
        will really provision a Twilio number to your account. You can use
301
        [Test Credentials][test-creds] to call this endpoint without making a
302
        real phone number purchase. If you do, you need to pass one of the
303
        [test phone numbers][test-numbers].
304

305
        The `number` should be in [E.164][e164] format.
306

307
        Every call or text to the relay number will be sent as a webhook to the
308
        URL configured for your `TWILIO_SMS_APPLICATION_SID`.
309

310
        [test-creds]: https://www.twilio.com/docs/iam/test-credentials
311
        [test-numbers]: https://www.twilio.com/docs/iam/test-credentials#test-incoming-phone-numbers-parameters-PhoneNumber
312
        [e164]: https://en.wikipedia.org/wiki/E.164
313
        """  # noqa: E501  # ignore long line for URL
314
        incr_if_enabled("phones_RelayNumberViewSet.create")
1✔
315
        existing_number = RelayNumber.objects.filter(user=request.user)
1✔
316
        if existing_number:
1!
317
            raise exceptions.ValidationError("User already has a RelayNumber.")
1✔
318
        return super().create(request, *args, **kwargs)
×
319

320
    def partial_update(self, request, *args, **kwargs):
1✔
321
        """
322
        Update the authenticated user's relay number.
323

324
        The authenticated user must have a subscription that grants one of the
325
        `SUBSCRIPTIONS_WITH_PHONE` capabilities.
326

327
        The `{id}` should match a previously-`POST`ed resource that belongs to
328
        the authenticated user.
329

330
        This is primarily used to toggle the `enabled` field.
331
        """
332
        incr_if_enabled("phones_RelayNumberViewSet.partial_update")
1✔
333
        return super().partial_update(request, *args, **kwargs)
1✔
334

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

375
        Based on the user's real number, returns available relay numbers:
376
          * `same_prefix_options`: Numbers that match as much of the user's
377
            real number as possible.
378
          * `other_areas_options`: Numbers that exactly match the user's real
379
            number, in a different area code.
380
          * `same_area_options`: Other numbers in the same area code as the user.
381
          * `random_options`: Available numbers in the user's country
382
        """
383
        incr_if_enabled("phones_RelayNumberViewSet.suggestions")
1✔
384
        numbers = suggested_numbers(request.user)
1✔
385
        return response.Response(numbers)
1✔
386

387
    @extend_schema(
1✔
388
        parameters=[
389
            OpenApiParameter(
390
                "location",
391
                required=False,
392
                location="query",
393
                examples=[OpenApiExample("Miami FL USA", "Miami")],
394
            ),
395
            OpenApiParameter(
396
                "area_code",
397
                required=False,
398
                location="query",
399
                examples=[OpenApiExample("Tulsa OK USA", "918")],
400
            ),
401
        ],
402
        responses={
403
            "200": OpenApiResponse(
404
                TwilioNumberSuggestion(many=True),
405
                description="List of available numbers",
406
                examples=[
407
                    OpenApiExample(
408
                        "Tulsa, OK",
409
                        {
410
                            "friendly_name": "(918) 555-6789",
411
                            "iso_country": "US",
412
                            "locality": "Tulsa",
413
                            "phone_number": "+19185556789",
414
                            "postal_code": "74120",
415
                            "region": "OK",
416
                        },
417
                    )
418
                ],
419
            ),
420
            "404": OpenApiResponse(
421
                description="Neither location or area_code was specified"
422
            ),
423
        },
424
    )
425
    @decorators.action(detail=False)
1✔
426
    def search(self, request):
1✔
427
        """
428
        Search for available numbers.
429

430
        This endpoints uses the underlying [AvailablePhoneNumbers][apn] API.
431

432
        Accepted query params:
433
          * ?location=
434
            * Will be passed to `AvailablePhoneNumbers` `in_locality` param
435
          * ?area_code=
436
            * Will be passed to `AvailablePhoneNumbers` `area_code` param
437

438
        [apn]: https://www.twilio.com/docs/phone-numbers/api/availablephonenumberlocal-resource#read-multiple-availablephonenumberlocal-resources
439
        """  # noqa: E501  # ignore long line for URL
440
        incr_if_enabled("phones_RelayNumberViewSet.search")
1✔
441
        try:
1✔
442
            country_code = RealPhone.verified_objects.country_code_for_user(
1✔
443
                request.user
444
            )
445
        except RealPhone.DoesNotExist:
1✔
446
            country_code = DEFAULT_REGION
1✔
447
        location = request.query_params.get("location")
1✔
448
        if location is not None:
1✔
449
            numbers = location_numbers(location, country_code)
1✔
450
            return response.Response(numbers)
1✔
451

452
        area_code = request.query_params.get("area_code")
1✔
453
        if area_code is not None:
1✔
454
            numbers = area_code_numbers(area_code, country_code)
1✔
455
            return response.Response(numbers)
1✔
456

457
        return response.Response({}, 404)
1✔
458

459

460
@extend_schema(tags=["phones"])
1✔
461
class InboundContactViewSet(viewsets.ModelViewSet):
1✔
462
    http_method_names = ["get", "patch"]
1✔
463
    permission_classes = [permissions.IsAuthenticated, HasPhoneService]
1✔
464
    serializer_class = InboundContactSerializer
1✔
465

466
    def get_queryset(self) -> QuerySet[InboundContact]:
1✔
467
        if isinstance(self.request.user, User):
1✔
468
            relay_number = get_object_or_404(RelayNumber, user=self.request.user)
1✔
469
            return InboundContact.objects.filter(relay_number=relay_number)
1✔
470
        return InboundContact.objects.none()
1✔
471

472

473
def _validate_number(request, number_field="number"):
1✔
474
    if number_field not in request.data:
1✔
475
        raise exceptions.ValidationError({number_field: "A number is required."})
1✔
476

477
    parsed_number = _parse_number(
1✔
478
        request.data[number_field], getattr(request, "country", None)
479
    )
480
    if not parsed_number:
1✔
481
        country = None
1✔
482
        if hasattr(request, "country"):
1!
483
            country = request.country
×
484
        error_message = (
1✔
485
            "number must be in E.164 format, or in local national format of the"
486
            f" country detected: {country}"
487
        )
488
        raise exceptions.ValidationError(error_message)
1✔
489

490
    e164_number = f"+{parsed_number.country_code}{parsed_number.national_number}"
1✔
491
    number_details = _get_number_details(e164_number)
1✔
492
    if not number_details:
1✔
493
        raise exceptions.ValidationError(
1✔
494
            f"Could not get number details for {e164_number}"
495
        )
496

497
    if number_details.country_code.upper() not in settings.TWILIO_ALLOWED_COUNTRY_CODES:
1✔
498
        incr_if_enabled("phones_validate_number_unsupported_country")
1✔
499
        raise exceptions.ValidationError(
1✔
500
            "Relay Phone is currently only available for these country codes: "
501
            f"{sorted(settings.TWILIO_ALLOWED_COUNTRY_CODES)!r}. "
502
            "Your phone number country code is: "
503
            f"'{number_details.country_code.upper()}'."
504
        )
505

506
    return number_details
1✔
507

508

509
def _parse_number(number, country=None):
1✔
510
    try:
1✔
511
        # First try to parse assuming number is E.164 with country prefix
512
        return phonenumbers.parse(number)
1✔
513
    except phonenumbers.phonenumberutil.NumberParseException as e:
1✔
514
        if e.error_type == e.INVALID_COUNTRY_CODE and country is not None:
1✔
515
            try:
1✔
516
                # Try to parse, assuming number is local national format
517
                # in the detected request country
518
                return phonenumbers.parse(number, country)
1✔
519
            except Exception:
×
520
                return None
×
521
    return None
1✔
522

523

524
def _get_number_details(e164_number):
1✔
525
    incr_if_enabled("phones_get_number_details")
1✔
526
    try:
1✔
527
        client = twilio_client()
1✔
528
        return client.lookups.v1.phone_numbers(e164_number).fetch(type=["carrier"])
1✔
529
    except Exception:
1✔
530
        logger.exception(f"Could not get number details for {e164_number}")
1✔
531
        return None
1✔
532

533

534
@extend_schema(
1✔
535
    tags=["phones"],
536
    responses={
537
        "200": OpenApiResponse(
538
            bytes,
539
            description="A Virtual Contact File (VCF) for the user's Relay number.",
540
            examples=[
541
                OpenApiExample(
542
                    name="partial VCF",
543
                    media_type="text/x-vcard",
544
                    value=(
545
                        "BEGIN:VCARD\nVERSION:3.0\nFN:Firefox Relay\n"
546
                        "TEL:+14045555555\nEND:VCARD\n"
547
                    ),
548
                )
549
            ],
550
        ),
551
        "404": OpenApiResponse(description="No or unknown lookup key"),
552
    },
553
)
554
@decorators.api_view()
1✔
555
@decorators.permission_classes([permissions.AllowAny])
1✔
556
@decorators.renderer_classes([vCardRenderer])
1✔
557
def vCard(request: Request, lookup_key: str) -> response.Response:
1✔
558
    """
559
    Get a Relay vCard. `lookup_key` should be passed in url path.
560

561
    We use this to return a vCard for a number. When we create a RelayNumber,
562
    we create a secret lookup_key and text it to the user.
563
    """
564
    incr_if_enabled("phones_vcard")
1✔
565
    if lookup_key is None:
1!
566
        return response.Response(status=404)
×
567

568
    try:
1✔
569
        relay_number = RelayNumber.objects.get(vcard_lookup_key=lookup_key)
1✔
570
    except RelayNumber.DoesNotExist:
1✔
571
        raise exceptions.NotFound()
1✔
572
    number = relay_number.number
1✔
573

574
    resp = response.Response({"number": number})
1✔
575
    resp["Content-Disposition"] = f"attachment; filename={number}.vcf"
1✔
576
    return resp
1✔
577

578

579
@extend_schema(
1✔
580
    tags=["phones"],
581
    request=OpenApiRequest(),
582
    responses={
583
        "200": OpenApiResponse(
584
            {"type": "object"},
585
            description="Welcome message sent.",
586
            examples=[OpenApiExample("success", {"msg": "sent"})],
587
        ),
588
        "401": OpenApiResponse(description="Not allowed"),
589
        "404": OpenApiResponse(description="User does not have a Relay number."),
590
    },
591
)
592
@decorators.api_view(["POST"])
1✔
593
@decorators.permission_classes([permissions.IsAuthenticated, HasPhoneService])
1✔
594
def resend_welcome_sms(request):
1✔
595
    """
596
    Resend the "Welcome" SMS, including vCard.
597

598
    Requires the user to be signed in and to have phone service.
599
    """
600
    incr_if_enabled("phones_resend_welcome_sms")
1✔
601
    try:
1✔
602
        relay_number = RelayNumber.objects.get(user=request.user)
1✔
603
    except RelayNumber.DoesNotExist:
×
604
        raise exceptions.NotFound()
×
605
    send_welcome_message(request.user, relay_number)
1✔
606

607
    resp = response.Response(status=201, data={"msg": "sent"})
1✔
608
    return resp
1✔
609

610

611
def _try_delete_from_twilio(message):
1✔
612
    try:
1✔
613
        message.delete()
1✔
614
    except TwilioRestException as e:
×
615
        # Raise the exception unless it's a 404 indicating the message is already gone
616
        if e.status != 404:
×
617
            raise e
×
618

619

620
def message_body(from_num, body):
1✔
621
    return f"[Relay 📲 {from_num}] {body}"
1✔
622

623

624
def _log_sms_exception(
1✔
625
    phone_provider: Literal["twilio", "iq"],
626
    real_phone: RealPhone,
627
    sms_exception: RelaySMSException,
628
) -> None:
629
    """Log SMS exceptions for incoming requests from the provider."""
630
    context = sms_exception.error_context()
1✔
631
    context["phone_provider"] = phone_provider
1✔
632
    context["fxa_id"] = real_phone.user.profile.metrics_fxa_id
1✔
633
    info_logger.info(sms_exception.default_code, context)
1✔
634

635

636
def _get_user_error_message(
1✔
637
    real_phone: RealPhone, sms_exception: RelaySMSException
638
) -> Any:
639
    """Generate a translated message for the user."""
640
    with django_ftl.override(real_phone.user.profile.language):
1✔
641
        user_message = ftl_bundle.format(
1✔
642
            sms_exception.ftl_id, sms_exception.error_context()
643
        )
644
    return user_message
1✔
645

646

647
@extend_schema(
1✔
648
    tags=["phones: Twilio"],
649
    parameters=[
650
        OpenApiParameter(name="X-Twilio-Signature", required=True, location="header"),
651
    ],
652
    request=OpenApiRequest(
653
        TwilioInboundSmsSerializer,
654
        examples=[
655
            OpenApiExample(
656
                "request",
657
                {"to": "+13035556789", "from": "+14045556789", "text": "Hello!"},
658
            )
659
        ],
660
    ),
661
    responses={
662
        "200": OpenApiResponse(
663
            {"type": "string", "xml": {"name": "Response"}},
664
            description="The number is disabled.",
665
            examples=[OpenApiExample("disabled", None)],
666
        ),
667
        "201": OpenApiResponse(
668
            {"type": "string", "xml": {"name": "Response"}},
669
            description="Forward the message to the user.",
670
            examples=[OpenApiExample("success", None)],
671
        ),
672
        "400": OpenApiResponse(
673
            {"type": "object", "xml": {"name": "Error"}},
674
            description="Unable to complete request.",
675
            examples=[
676
                OpenApiExample(
677
                    "invalid signature",
678
                    {
679
                        "status_code": 400,
680
                        "code": "invalid",
681
                        "title": "Invalid Request: Invalid Signature",
682
                    },
683
                )
684
            ],
685
        ),
686
    },
687
)
688
@decorators.api_view(["POST"])
1✔
689
@decorators.permission_classes([permissions.AllowAny])
1✔
690
@decorators.renderer_classes([TemplateTwiMLRenderer])
1✔
691
def inbound_sms(request):
1✔
692
    """
693
    Handle an inbound SMS message sent by Twilio.
694

695
    The return value is TwilML Response XML that reports the error or an empty success
696
    message.
697
    """
698
    incr_if_enabled("phones_inbound_sms")
1✔
699
    _validate_twilio_request(request)
1✔
700

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

705
    inbound_msg_sid = request.data.get("MessageSid", None)
706
    if inbound_msg_sid is None:
707
        raise exceptions.ValidationError("Request missing MessageSid")
708
    tasks._try_delete_from_twilio.delay(args=message, countdown=10)
709
    """
710

711
    inbound_body = request.data.get("Body", None)
1✔
712
    inbound_from = request.data.get("From", None)
1✔
713
    inbound_to = request.data.get("To", None)
1✔
714
    if inbound_body is None or inbound_from is None or inbound_to is None:
1✔
715
        raise exceptions.ValidationError("Request missing From, To, Or Body.")
1✔
716

717
    relay_number, real_phone = _get_phone_objects(inbound_to)
1✔
718
    if not real_phone.user.is_active:
1✔
719
        return response.Response(
1✔
720
            status=200,
721
            template_name="twiml_empty_response.xml",
722
        )
723

724
    _check_remaining(relay_number, "texts")
1✔
725

726
    if inbound_from == real_phone.number:
1✔
727
        prepared = False
1✔
728
        try:
1✔
729
            relay_number, destination_number, body = _prepare_sms_reply(
1✔
730
                relay_number, inbound_body
731
            )
732
            prepared = True
1✔
733
        except RelaySMSException as sms_exception:
1✔
734
            _log_sms_exception("twilio", real_phone, sms_exception)
1✔
735
            user_error_message = _get_user_error_message(real_phone, sms_exception)
1✔
736
            twilio_client().messages.create(
1✔
737
                from_=relay_number.number, body=user_error_message, to=real_phone.number
738
            )
739
            if sms_exception.status_code >= 400:
1✔
740
                raise
1✔
741

742
        if prepared:
1✔
743
            client = twilio_client()
1✔
744
            incr_if_enabled("phones_send_sms_reply")
1✔
745
            success = False
1✔
746
            try:
1✔
747
                client.messages.create(
1✔
748
                    from_=relay_number.number, body=body, to=destination_number
749
                )
750
                success = True
1✔
751
            except TwilioRestException as e:
1✔
752
                logger.error(
1✔
753
                    "Twilio failed to send reply",
754
                    {"code": e.code, "http_status_code": e.status, "msg": e.msg},
755
                )
756
            if success:
1✔
757
                relay_number.remaining_texts -= 1
1✔
758
                relay_number.texts_forwarded += 1
1✔
759
                relay_number.save()
1✔
760

761
        return response.Response(
1✔
762
            status=200,
763
            template_name="twiml_empty_response.xml",
764
        )
765

766
    number_disabled = _check_disabled(relay_number, "texts")
1✔
767
    if number_disabled:
1✔
768
        return response.Response(
1✔
769
            status=200,
770
            template_name="twiml_empty_response.xml",
771
        )
772
    inbound_contact = _get_inbound_contact(relay_number, inbound_from)
1✔
773
    if inbound_contact:
1✔
774
        _check_and_update_contact(inbound_contact, "texts", relay_number)
1✔
775

776
    client = twilio_client()
1✔
777
    app = twiml_app()
1✔
778
    incr_if_enabled("phones_outbound_sms")
1✔
779
    body = message_body(inbound_from, inbound_body)
1✔
780
    result = "SUCCESS"
1✔
781
    try:
1✔
782
        client.messages.create(
1✔
783
            from_=relay_number.number,
784
            body=body,
785
            status_callback=app.sms_status_callback,
786
            to=real_phone.number,
787
        )
788
    except TwilioRestException as e:
1✔
789
        if e.code == 21610:
1✔
790
            # User has opted out with "STOP"
791
            # TODO: Mark RealPhone as unsubscribed?
792
            context = {"code": e.code, "http_status_code": e.status, "msg": e.msg}
1✔
793
            context["fxa_id"] = real_phone.user.profile.metrics_fxa_id
1✔
794
            info_logger.info("User has blocked their Relay number", context)
1✔
795
            result = "BLOCKED"
1✔
796
        else:
797
            result = "FAILED"
1✔
798
            logger.error(
1✔
799
                "Twilio failed to forward message",
800
                {"code": e.code, "http_status_code": e.status, "msg": e.msg},
801
            )
802
    if result == "SUCCESS":
1✔
803
        relay_number.remaining_texts -= 1
1✔
804
        relay_number.texts_forwarded += 1
1✔
805
        relay_number.save()
1✔
806
    elif result == "BLOCKED":
1✔
807
        relay_number.texts_blocked += 1
1✔
808
        relay_number.save()
1✔
809

810
    return response.Response(
1✔
811
        status=201,
812
        template_name="twiml_empty_response.xml",
813
    )
814

815

816
@extend_schema(
1✔
817
    tags=["phones: Inteliquent"],
818
    request=OpenApiRequest(
819
        IqInboundSmsSerializer,
820
        examples=[
821
            OpenApiExample(
822
                "request",
823
                {"to": "+13035556789", "from": "+14045556789", "text": "Hello!"},
824
            )
825
        ],
826
    ),
827
    parameters=[
828
        OpenApiParameter(name="VerificationToken", required=True, location="header"),
829
        OpenApiParameter(name="MessageId", required=True, location="header"),
830
    ],
831
    responses={
832
        "200": OpenApiResponse(
833
            description=(
834
                "The message was forwarded, or the user is out of text messages."
835
            )
836
        ),
837
        "401": OpenApiResponse(description="Invalid signature"),
838
        "400": OpenApiResponse(description="Invalid request"),
839
    },
840
)
841
@decorators.api_view(["POST"])
1✔
842
@decorators.permission_classes([permissions.AllowAny])
1✔
843
def inbound_sms_iq(request: Request) -> response.Response:
1✔
844
    """Handle an inbound SMS message sent by Inteliquent."""
845
    incr_if_enabled("phones_inbound_sms_iq")
×
846
    _validate_iq_request(request)
×
847

848
    inbound_body = request.data.get("text", None)
×
849
    inbound_from = request.data.get("from", None)
×
850
    inbound_to = request.data.get("to", None)
×
851
    if inbound_body is None or inbound_from is None or inbound_to is None:
×
852
        raise exceptions.ValidationError("Request missing from, to, or text.")
×
853

854
    from_num = phonenumbers.format_number(
×
855
        phonenumbers.parse(inbound_from, DEFAULT_REGION),
856
        phonenumbers.PhoneNumberFormat.E164,
857
    )
858
    single_num = inbound_to[0]
×
859
    relay_num = phonenumbers.format_number(
×
860
        phonenumbers.parse(single_num, DEFAULT_REGION),
861
        phonenumbers.PhoneNumberFormat.E164,
862
    )
863

864
    relay_number, real_phone = _get_phone_objects(relay_num)
×
865
    _check_remaining(relay_number, "texts")
×
866

867
    if from_num == real_phone.number:
×
868
        try:
×
869
            relay_number, destination_number, body = _prepare_sms_reply(
×
870
                relay_number, inbound_body
871
            )
872
            send_iq_sms(destination_number, relay_number.number, body)
×
873
            relay_number.remaining_texts -= 1
×
874
            relay_number.texts_forwarded += 1
×
875
            relay_number.save()
×
876
            incr_if_enabled("phones_send_sms_reply_iq")
×
877
        except RelaySMSException as sms_exception:
×
878
            _log_sms_exception("iq", real_phone, sms_exception)
×
879
            user_error_message = _get_user_error_message(real_phone, sms_exception)
×
880
            send_iq_sms(real_phone.number, relay_number.number, user_error_message)
×
881
            if sms_exception.status_code >= 400:
×
882
                raise
×
883

884
        return response.Response(
×
885
            status=200,
886
            template_name="twiml_empty_response.xml",
887
        )
888

889
    number_disabled = _check_disabled(relay_number, "texts")
×
890
    if number_disabled:
×
891
        return response.Response(status=200)
×
892

893
    inbound_contact = _get_inbound_contact(relay_number, from_num)
×
894
    if inbound_contact:
×
895
        _check_and_update_contact(inbound_contact, "texts", relay_number)
×
896

897
    text = message_body(inbound_from, inbound_body)
×
898
    send_iq_sms(real_phone.number, relay_number.number, text)
×
899

900
    relay_number.remaining_texts -= 1
×
901
    relay_number.texts_forwarded += 1
×
902
    relay_number.save()
×
903
    return response.Response(status=200)
×
904

905

906
@extend_schema(
1✔
907
    tags=["phones: Twilio"],
908
    parameters=[
909
        OpenApiParameter(name="X-Twilio-Signature", required=True, location="header"),
910
    ],
911
    request=OpenApiRequest(
912
        TwilioInboundCallSerializer,
913
        examples=[
914
            OpenApiExample(
915
                "request",
916
                {"Caller": "+13035556789", "Called": "+14045556789"},
917
            )
918
        ],
919
    ),
920
    responses={
921
        "200": OpenApiResponse(
922
            {
923
                "type": "object",
924
                "xml": {"name": "Response"},
925
                "properties": {"say": {"type": "string"}},
926
            },
927
            description="The number is disabled.",
928
            examples=[
929
                OpenApiExample(
930
                    "disabled", {"say": "Sorry, that number is not available."}
931
                )
932
            ],
933
        ),
934
        "201": OpenApiResponse(
935
            {
936
                "type": "object",
937
                "xml": {"name": "Response"},
938
                "properties": {
939
                    "Dial": {
940
                        "type": "object",
941
                        "properties": {
942
                            "callerId": {
943
                                "type": "string",
944
                                "xml": {"attribute": "true"},
945
                            },
946
                            "Number": {"type": "string"},
947
                        },
948
                    }
949
                },
950
            },
951
            description="Connect the caller to the Relay user.",
952
            examples=[
953
                OpenApiExample(
954
                    "success",
955
                    {"Dial": {"callerId": "+13035556789", "Number": "+15025558642"}},
956
                )
957
            ],
958
        ),
959
        "400": OpenApiResponse(
960
            {"type": "object", "xml": {"name": "Error"}},
961
            description="Unable to complete request.",
962
            examples=[
963
                OpenApiExample(
964
                    "invalid signature",
965
                    {
966
                        "status_code": 400,
967
                        "code": "invalid",
968
                        "title": "Invalid Request: Invalid Signature",
969
                    },
970
                ),
971
                OpenApiExample(
972
                    "out of call time for month",
973
                    {
974
                        "status_code": 400,
975
                        "code": "invalid",
976
                        "title": "Number Is Out Of Seconds.",
977
                    },
978
                ),
979
            ],
980
        ),
981
    },
982
)
983
@decorators.api_view(["POST"])
1✔
984
@decorators.permission_classes([permissions.AllowAny])
1✔
985
@decorators.renderer_classes([TemplateTwiMLRenderer])
1✔
986
def inbound_call(request):
1✔
987
    """
988
    Handle an inbound call request sent by Twilio.
989

990
    The return value is TwilML Response XML that reports the error or instructs
991
    Twilio to connect the callers.
992
    """
993
    incr_if_enabled("phones_inbound_call")
1✔
994
    _validate_twilio_request(request)
1✔
995
    inbound_from = request.data.get("Caller", None)
1✔
996
    inbound_to = request.data.get("Called", None)
1✔
997
    if inbound_from is None or inbound_to is None:
1✔
998
        raise exceptions.ValidationError("Call data missing Caller or Called.")
1✔
999

1000
    relay_number, real_phone = _get_phone_objects(inbound_to)
1✔
1001
    if not real_phone.user.is_active:
1✔
1002
        return response.Response(
1✔
1003
            status=200,
1004
            template_name="twiml_empty_response.xml",
1005
        )
1006

1007
    number_disabled = _check_disabled(relay_number, "calls")
1✔
1008
    if number_disabled:
1✔
1009
        say = "Sorry, that number is not available."
1✔
1010
        return response.Response(
1✔
1011
            {"say": say}, status=200, template_name="twiml_blocked.xml"
1012
        )
1013

1014
    _check_remaining(relay_number, "seconds")
1✔
1015

1016
    inbound_contact = _get_inbound_contact(relay_number, inbound_from)
1✔
1017
    if inbound_contact:
1!
1018
        _check_and_update_contact(inbound_contact, "calls", relay_number)
1✔
1019

1020
    relay_number.calls_forwarded += 1
1✔
1021
    relay_number.save()
1✔
1022

1023
    # Note: TemplateTwiMLRenderer will render this as TwiML
1024
    incr_if_enabled("phones_outbound_call")
1✔
1025
    return response.Response(
1✔
1026
        {"inbound_from": inbound_from, "real_number": real_phone.number},
1027
        status=201,
1028
        template_name="twiml_dial.xml",
1029
    )
1030

1031

1032
@extend_schema(
1✔
1033
    tags=["phones: Twilio"],
1034
    request=OpenApiRequest(
1035
        TwilioVoiceStatusSerializer,
1036
        examples=[
1037
            OpenApiExample(
1038
                "Call is complete",
1039
                {
1040
                    "CallSid": "CA" + "x" * 32,
1041
                    "Called": "+14045556789",
1042
                    "CallStatus": "completed",
1043
                    "CallDuration": 127,
1044
                },
1045
            )
1046
        ],
1047
    ),
1048
    parameters=[
1049
        OpenApiParameter(name="X-Twilio-Signature", required=True, location="header"),
1050
    ],
1051
    responses={
1052
        "200": OpenApiResponse(description="Call status was processed."),
1053
        "400": OpenApiResponse(
1054
            description="Required parameters are incorrect or missing."
1055
        ),
1056
    },
1057
)
1058
@decorators.api_view(["POST"])
1✔
1059
@decorators.permission_classes([permissions.AllowAny])
1✔
1060
def voice_status(request):
1✔
1061
    """
1062
    Twilio callback for voice call status.
1063

1064
    When the call is complete, the user's remaining monthly time is updated, and
1065
    the call is deleted from Twilio logs.
1066
    """
1067
    incr_if_enabled("phones_voice_status")
1✔
1068
    _validate_twilio_request(request)
1✔
1069
    call_sid = request.data.get("CallSid", None)
1✔
1070
    called = request.data.get("Called", None)
1✔
1071
    call_status = request.data.get("CallStatus", None)
1✔
1072
    if call_sid is None or called is None or call_status is None:
1✔
1073
        raise exceptions.ValidationError("Call data missing Called, CallStatus")
1✔
1074
    if call_status != "completed":
1✔
1075
        return response.Response(status=200)
1✔
1076
    call_duration = request.data.get("CallDuration", None)
1✔
1077
    if call_duration is None:
1✔
1078
        raise exceptions.ValidationError("completed call data missing CallDuration")
1✔
1079
    relay_number, _ = _get_phone_objects(called)
1✔
1080
    relay_number.remaining_seconds = relay_number.remaining_seconds - int(call_duration)
1✔
1081
    relay_number.save()
1✔
1082
    if relay_number.remaining_seconds < 0:
1✔
1083
        info_logger.info(
1✔
1084
            "phone_limit_exceeded",
1085
            extra={
1086
                "fxa_uid": relay_number.user.profile.metrics_fxa_id,
1087
                "call_duration_in_seconds": int(call_duration),
1088
                "relay_number_enabled": relay_number.enabled,
1089
                "remaining_seconds": relay_number.remaining_seconds,
1090
                "remaining_minutes": relay_number.remaining_minutes,
1091
            },
1092
        )
1093
    client = twilio_client()
1✔
1094
    client.calls(call_sid).delete()
1✔
1095
    return response.Response(status=200)
1✔
1096

1097

1098
@extend_schema(
1✔
1099
    tags=["phones: Twilio"],
1100
    request=OpenApiRequest(
1101
        TwilioSmsStatusSerializer,
1102
        examples=[
1103
            OpenApiExample(
1104
                "SMS is delivered",
1105
                {"SmsStatus": "delivered", "MessageSid": "SM" + "x" * 32},
1106
            )
1107
        ],
1108
    ),
1109
    parameters=[
1110
        OpenApiParameter(name="X-Twilio-Signature", required=True, location="header"),
1111
    ],
1112
    responses={
1113
        "200": OpenApiResponse(description="SMS status was processed."),
1114
        "400": OpenApiResponse(
1115
            description="Required parameters are incorrect or missing."
1116
        ),
1117
    },
1118
)
1119
@decorators.api_view(["POST"])
1✔
1120
@decorators.permission_classes([permissions.AllowAny])
1✔
1121
def sms_status(request):
1✔
1122
    """
1123
    Twilio callback for SMS status.
1124

1125
    When the message is delivered, this calls Twilio to delete the message from logs.
1126
    """
1127
    _validate_twilio_request(request)
1✔
1128
    sms_status = request.data.get("SmsStatus", None)
1✔
1129
    message_sid = request.data.get("MessageSid", None)
1✔
1130
    if sms_status is None or message_sid is None:
1✔
1131
        raise exceptions.ValidationError(
1✔
1132
            "Text status data missing SmsStatus or MessageSid"
1133
        )
1134
    if sms_status != "delivered":
1✔
1135
        return response.Response(status=200)
1✔
1136
    client = twilio_client()
1✔
1137
    message = client.messages(message_sid)
1✔
1138
    _try_delete_from_twilio(message)
1✔
1139
    return response.Response(status=200)
1✔
1140

1141

1142
@decorators.permission_classes([permissions.IsAuthenticated, HasPhoneService])
1✔
1143
@extend_schema(
1✔
1144
    tags=["phones: Outbound"],
1145
    request=OpenApiRequest(
1146
        OutboundCallSerializer,
1147
        examples=[OpenApiExample("request", {"to": "+13035556789"})],
1148
    ),
1149
    responses={
1150
        200: OpenApiResponse(description="Call initiated."),
1151
        400: OpenApiResponse(
1152
            description="Input error, or user does not have a Relay phone."
1153
        ),
1154
        401: OpenApiResponse(description="Authentication required."),
1155
        403: OpenApiResponse(
1156
            description="User does not have 'outbound_phone' waffle flag."
1157
        ),
1158
    },
1159
)
1160
@decorators.api_view(["POST"])
1✔
1161
def outbound_call(request):
1✔
1162
    """Make a call from the authenticated user's relay number."""
1163
    # TODO: Create or update an OutboundContact (new model) on send, or limit
1164
    # to InboundContacts.
1165
    if not flag_is_active(request, "outbound_phone"):
1✔
1166
        # Return Permission Denied error
1167
        return response.Response(
1✔
1168
            {"detail": "Requires outbound_phone waffle flag."}, status=403
1169
        )
1170
    try:
1✔
1171
        real_phone = RealPhone.verified_objects.get_for_user(user=request.user)
1✔
1172
    except RealPhone.DoesNotExist:
1✔
1173
        return response.Response(
1✔
1174
            {"detail": "Requires a verified real phone and phone mask."}, status=400
1175
        )
1176
    try:
1✔
1177
        relay_number = RelayNumber.objects.get(user=request.user)
1✔
1178
    except RelayNumber.DoesNotExist:
1✔
1179
        return response.Response({"detail": "Requires a phone mask."}, status=400)
1✔
1180

1181
    client = twilio_client()
1✔
1182

1183
    to = _validate_number(request, "to")  # Raises ValidationError on invalid number
1✔
1184
    client.calls.create(
1✔
1185
        twiml=(
1186
            f"<Response><Say>Dialing {to.national_format} ...</Say>"
1187
            f"<Dial>{to.phone_number}</Dial></Response>"
1188
        ),
1189
        to=real_phone.number,
1190
        from_=relay_number.number,
1191
    )
1192
    return response.Response(status=200)
1✔
1193

1194

1195
@decorators.permission_classes([permissions.IsAuthenticated, HasPhoneService])
1✔
1196
@extend_schema(
1✔
1197
    tags=["phones: Outbound"],
1198
    request=OpenApiRequest(
1199
        OutboundSmsSerializer,
1200
        examples=[
1201
            OpenApiExample("request", {"body": "Hello!", "destination": "+13045554567"})
1202
        ],
1203
    ),
1204
    responses={
1205
        200: OpenApiResponse(description="Message sent."),
1206
        400: OpenApiResponse(
1207
            description="Input error, or user does not have a Relay phone."
1208
        ),
1209
        401: OpenApiResponse(description="Authentication required."),
1210
        403: OpenApiResponse(
1211
            description="User does not have 'outbound_phone' waffle flag."
1212
        ),
1213
    },
1214
)
1215
@decorators.api_view(["POST"])
1✔
1216
def outbound_sms(request):
1✔
1217
    """
1218
    Send a message from the user's relay number.
1219

1220
    POST params:
1221
        body: the body of the message
1222
        destination: E.164-formatted phone number
1223

1224
    """
1225
    # TODO: Create or update an OutboundContact (new model) on send, or limit
1226
    # to InboundContacts.
1227
    # TODO: Reduce user's SMS messages for the month by one
1228
    if not flag_is_active(request, "outbound_phone"):
1✔
1229
        return response.Response(
1✔
1230
            {"detail": "Requires outbound_phone waffle flag."}, status=403
1231
        )
1232
    try:
1✔
1233
        relay_number = RelayNumber.objects.get(user=request.user)
1✔
1234
    except RelayNumber.DoesNotExist:
1✔
1235
        return response.Response({"detail": "Requires a phone mask."}, status=400)
1✔
1236

1237
    errors = {}
1✔
1238
    body = request.data.get("body")
1✔
1239
    if not body:
1✔
1240
        errors["body"] = "A message body is required."
1✔
1241
    destination_number = request.data.get("destination")
1✔
1242
    if not destination_number:
1✔
1243
        errors["destination"] = "A destination number is required."
1✔
1244
    if errors:
1✔
1245
        return response.Response(errors, status=400)
1✔
1246

1247
    # Raises ValidationError on invalid number
1248
    to = _validate_number(request, "destination")
1✔
1249

1250
    client = twilio_client()
1✔
1251
    client.messages.create(from_=relay_number.number, body=body, to=to.phone_number)
1✔
1252
    return response.Response(status=200)
1✔
1253

1254

1255
@decorators.permission_classes([permissions.IsAuthenticated, HasPhoneService])
1✔
1256
@extend_schema(
1✔
1257
    tags=["phones: Outbound"],
1258
    parameters=[
1259
        OpenApiParameter(
1260
            name="with",
1261
            description="filter to messages with the given E.164 number",
1262
        ),
1263
        OpenApiParameter(
1264
            name="direction",
1265
            enum=["inbound", "outbound"],
1266
            description="filter to inbound or outbound messages",
1267
        ),
1268
    ],
1269
    responses={
1270
        "200": OpenApiResponse(
1271
            TwilioMessagesSerializer(many=True),
1272
            description="A list of the user's SMS messages.",
1273
            examples=[
1274
                OpenApiExample(
1275
                    "success",
1276
                    {
1277
                        "to": "+13035556789",
1278
                        "date_sent": datetime.now(UTC).isoformat(),
1279
                        "body": "Hello!",
1280
                        "from": "+14045556789",
1281
                    },
1282
                )
1283
            ],
1284
        ),
1285
        "400": OpenApiResponse(description="Unable to complete request."),
1286
        "403": OpenApiResponse(
1287
            description="Caller does not have 'outbound_phone' waffle flag."
1288
        ),
1289
    },
1290
)
1291
@decorators.api_view(["GET"])
1✔
1292
def list_messages(request):
1✔
1293
    """
1294
    Get the user's SMS messages sent to or from the phone mask
1295

1296
    Pass ?with=<E.164> parameter to filter the messages to only the ones sent between
1297
    the phone mask and the <E.164> number.
1298

1299
    Pass ?direction=inbound|outbound to filter the messages to only the inbound or
1300
    outbound messages. If omitted, return both.
1301
    """
1302
    # TODO: Support filtering to messages for outbound-only phones.
1303
    # TODO: Show data from our own (encrypted) store, rather than from Twilio's
1304

1305
    if not flag_is_active(request, "outbound_phone"):
1✔
1306
        return response.Response(
1✔
1307
            {"detail": "Requires outbound_phone waffle flag."}, status=403
1308
        )
1309
    try:
1✔
1310
        relay_number = RelayNumber.objects.get(user=request.user)
1✔
1311
    except RelayNumber.DoesNotExist:
1✔
1312
        return response.Response({"detail": "Requires a phone mask."}, status=400)
1✔
1313

1314
    _with = request.query_params.get("with", None)
1✔
1315
    _direction = request.query_params.get("direction", None)
1✔
1316
    if _direction and _direction not in ("inbound", "outbound"):
1✔
1317
        return response.Response(
1✔
1318
            {"direction": "Invalid value, valid values are 'inbound' or 'outbound'"},
1319
            status=400,
1320
        )
1321

1322
    contact = None
1✔
1323
    if _with:
1✔
1324
        try:
1✔
1325
            contact = InboundContact.objects.get(
1✔
1326
                relay_number=relay_number, inbound_number=_with
1327
            )
1328
        except InboundContact.DoesNotExist:
1✔
1329
            return response.Response(
1✔
1330
                {"with": "No inbound contacts matching the number"}, status=400
1331
            )
1332

1333
    data = {}
1✔
1334
    client = twilio_client()
1✔
1335
    if not _direction or _direction == "inbound":
1✔
1336
        # Query Twilio for SMS messages to the user's phone mask
1337
        params = {"to": relay_number.number}
1✔
1338
        if contact:
1✔
1339
            # Filter query to SMS from this contact to the phone mask
1340
            params["from_"] = contact.inbound_number
1✔
1341
        data["inbound_messages"] = convert_twilio_messages_to_dict(
1✔
1342
            client.messages.list(**params)
1343
        )
1344
    if not _direction or _direction == "outbound":
1✔
1345
        # Query Twilio for SMS messages from the user's phone mask
1346
        params = {"from_": relay_number.number}
1✔
1347
        if contact:
1✔
1348
            # Filter query to SMS from the phone mask to this contact
1349
            params["to"] = contact.inbound_number
1✔
1350
        data["outbound_messages"] = convert_twilio_messages_to_dict(
1✔
1351
            client.messages.list(**params)
1352
        )
1353
    return response.Response(data, status=200)
1✔
1354

1355

1356
def _get_phone_objects(inbound_to):
1✔
1357
    # Get RelayNumber and RealPhone
1358
    try:
1✔
1359
        relay_number = RelayNumber.objects.get(number=inbound_to)
1✔
1360
        real_phone = RealPhone.verified_objects.get_for_user(relay_number.user)
1✔
1361
    except ObjectDoesNotExist:
1✔
1362
        raise exceptions.ValidationError("Could not find relay number.")
1✔
1363

1364
    return relay_number, real_phone
1✔
1365

1366

1367
def _prepare_sms_reply(
1✔
1368
    relay_number: RelayNumber, inbound_body: str
1369
) -> tuple[RelayNumber, str, str]:
1370
    incr_if_enabled("phones_handle_sms_reply")
1✔
1371
    if not relay_number.storing_phone_log:
1✔
1372
        # We do not store user's contacts in our database
1373
        raise NoPhoneLog()
1✔
1374

1375
    match = _match_senders_by_prefix(relay_number, inbound_body)
1✔
1376

1377
    # Fail if prefix match is ambiguous
1378
    if match and not match.contacts and match.match_type == "short":
1✔
1379
        raise ShortPrefixMatchesNoSenders(short_prefix=match.detected)
1✔
1380
    if match and not match.contacts and match.match_type == "full":
1✔
1381
        raise FullNumberMatchesNoSenders(full_number=match.detected)
1✔
1382
    if match and len(match.contacts) > 1:
1✔
1383
        if not match.match_type == "short":
1!
1384
            raise ValueError("match.match_type must be 'short'.")
×
1385
        raise MultipleNumberMatches(short_prefix=match.detected)
1✔
1386

1387
    # Determine the destination number
1388
    destination_number: str | None = None
1✔
1389
    if match:
1✔
1390
        # Use the sender matched by the prefix
1391
        if not len(match.contacts) == 1:
1!
1392
            raise ValueError("len(match.contacts) must be 1.")
×
1393
        destination_number = match.contacts[0].inbound_number
1✔
1394
    else:
1395
        # No prefix, default to last sender if any
1396
        last_sender = get_last_text_sender(relay_number)
1✔
1397
        destination_number = getattr(last_sender, "inbound_number", None)
1✔
1398

1399
    # Fail if no last sender
1400
    if destination_number is None:
1✔
1401
        raise NoPreviousSender()
1✔
1402

1403
    # Determine the message body
1404
    if match:
1✔
1405
        body = inbound_body.removeprefix(match.prefix)
1✔
1406
    else:
1407
        body = inbound_body
1✔
1408

1409
    # Fail if the prefix matches a sender, but there is no body to send
1410
    if match and not body and match.match_type == "short":
1✔
1411
        raise NoBodyAfterShortPrefix(short_prefix=match.detected)
1✔
1412
    if match and not body and match.match_type == "full":
1✔
1413
        raise NoBodyAfterFullNumber(full_number=match.detected)
1✔
1414

1415
    return (relay_number, destination_number, body)
1✔
1416

1417

1418
@dataclass
1✔
1419
class MatchByPrefix:
1✔
1420
    """Details of parsing a text message for a prefix."""
1421

1422
    # Was it matched by short code or full number?
1423
    match_type: Literal["short", "full"]
1✔
1424
    # The prefix portion of the text message
1425
    prefix: str
1✔
1426
    # The detected short code or full number
1427
    detected: str
1✔
1428
    # The matching numbers, as e.164 strings, empty if None
1429
    numbers: list[str] = field(default_factory=list)
1✔
1430

1431

1432
@dataclass
1✔
1433
class MatchData(MatchByPrefix):
1✔
1434
    """Details of expanding a MatchByPrefix with InboundContacts."""
1435

1436
    # The matching InboundContacts
1437
    contacts: list[InboundContact] = field(default_factory=list)
1✔
1438

1439

1440
def _match_senders_by_prefix(relay_number: RelayNumber, text: str) -> MatchData | None:
1✔
1441
    """
1442
    Match a prefix to previous InboundContact(s).
1443

1444
    If no prefix was found, returns None
1445
    If a prefix was found, a MatchData object has details and matching InboundContacts
1446
    """
1447
    multi_replies_flag, _ = get_waffle_flag_model().objects.get_or_create(
1✔
1448
        name="multi_replies",
1449
        defaults={
1450
            "note": (
1451
                "MPP-2252: Use prefix on SMS text to specify the recipient,"
1452
                " rather than default of last contact."
1453
            )
1454
        },
1455
    )
1456

1457
    if (
1✔
1458
        multi_replies_flag.is_active_for_user(relay_number.user)
1459
        or multi_replies_flag.everyone
1460
    ):
1461
        # Load all the previous contacts, collect possible countries
1462
        contacts = InboundContact.objects.filter(relay_number=relay_number).all()
1✔
1463
        contacts_by_number: dict[str, InboundContact] = {}
1✔
1464
        for contact in contacts:
1✔
1465
            # TODO: don't default to US when we support other regions
1466
            try:
1✔
1467
                pn = phonenumbers.parse(contact.inbound_number, DEFAULT_REGION)
1✔
1468
            except phonenumbers.phonenumberutil.NumberParseException:
1✔
1469
                # Invalid number like '1', skip it
1470
                continue
1✔
1471
            e164 = phonenumbers.format_number(pn, phonenumbers.PhoneNumberFormat.E164)
1✔
1472
            if e164 not in contacts_by_number:
1!
1473
                contacts_by_number[e164] = contact
1✔
1474

1475
        match = _match_by_prefix(text, set(contacts_by_number.keys()))
1✔
1476
        if match:
1✔
1477
            return MatchData(
1✔
1478
                contacts=[contacts_by_number[num] for num in match.numbers],
1479
                **asdict(match),
1480
            )
1481
    return None
1✔
1482

1483

1484
_SMS_SHORT_PREFIX_RE = re.compile(
1✔
1485
    r"""
1486
^               # Start of string
1487
\s*             # One or more spaces
1488
\d{4}           # 4 digits
1489
\s*             # Optional whitespace
1490
[:]?     # At most one separator, sync with SMS_SEPARATORS below
1491
\s*             # Trailing whitespace
1492
""",
1493
    re.VERBOSE | re.ASCII,
1494
)
1495
_SMS_SEPARATORS = set(":")  # Sync with SMS_SHORT_PREFIX_RE above
1✔
1496

1497

1498
def _match_by_prefix(text: str, candidate_numbers: set[str]) -> MatchByPrefix | None:
1✔
1499
    """
1500
    Look for a prefix in a text message matching a set of candidate numbers.
1501

1502
    Arguments:
1503
    * A SMS text message
1504
    * A set of phone numbers in E.164 format
1505

1506
    Return None if no prefix was found, or MatchByPrefix with likely match(es)
1507
    """
1508
    # Gather potential region codes, needed by PhoneNumberMatcher
1509
    region_codes = set()
1✔
1510
    for candidate_number in candidate_numbers:
1✔
1511
        pn = phonenumbers.parse(candidate_number)
1✔
1512
        if pn.country_code:
1!
1513
            region_codes |= set(
1✔
1514
                phonenumbers.region_codes_for_country_code(pn.country_code)
1515
            )
1516

1517
    # Determine where the message may start
1518
    #  PhoneNumberMatcher doesn't work well with a number directly followed by text,
1519
    #  so just feed it the start of the message that _may_ be a number.
1520
    msg_start = 0
1✔
1521
    phone_characters = set(string.digits + string.punctuation + string.whitespace)
1✔
1522
    while msg_start < len(text) and text[msg_start] in phone_characters:
1✔
1523
        msg_start += 1
1✔
1524

1525
    # Does PhoneNumberMatcher detect a full number at start of message?
1526
    text_to_match = text[:msg_start]
1✔
1527
    for region_code in region_codes:
1✔
1528
        for match in phonenumbers.PhoneNumberMatcher(text_to_match, region_code):
1✔
1529
            e164 = phonenumbers.format_number(
1✔
1530
                match.number, phonenumbers.PhoneNumberFormat.E164
1531
            )
1532

1533
            # Look for end of prefix
1534
            end = match.start + len(match.raw_string)
1✔
1535
            found_one_sep = False
1✔
1536
            while True:
1✔
1537
                if end >= len(text):
1✔
1538
                    break
1✔
1539
                elif text[end].isspace():
1✔
1540
                    end += 1
1✔
1541
                elif text[end] in _SMS_SEPARATORS and not found_one_sep:
1✔
1542
                    found_one_sep = True
1✔
1543
                    end += 1
1✔
1544
                else:
1545
                    break
1✔
1546

1547
            prefix = text[:end]
1✔
1548
            if e164 in candidate_numbers:
1✔
1549
                numbers = [e164]
1✔
1550
            else:
1551
                numbers = []
1✔
1552
            return MatchByPrefix(
1✔
1553
                match_type="full", prefix=prefix, detected=e164, numbers=numbers
1554
            )
1555

1556
    # Is there a short prefix? Return all contacts whose last 4 digits match.
1557
    text_prefix_match = _SMS_SHORT_PREFIX_RE.match(text)
1✔
1558
    if text_prefix_match:
1✔
1559
        text_prefix = text_prefix_match.group(0)
1✔
1560
        digits = set(string.digits)
1✔
1561
        digit_suffix = "".join(digit for digit in text_prefix if digit in digits)
1✔
1562
        numbers = [e164 for e164 in candidate_numbers if e164[-4:] == digit_suffix]
1✔
1563
        return MatchByPrefix(
1✔
1564
            match_type="short",
1565
            prefix=text_prefix,
1566
            detected=digit_suffix,
1567
            numbers=sorted(numbers),
1568
        )
1569

1570
    # No prefix detected
1571
    return None
1✔
1572

1573

1574
def _check_disabled(relay_number, contact_type):
1✔
1575
    # Check if RelayNumber is disabled
1576
    if not relay_number.enabled:
1✔
1577
        attr = f"{contact_type}_blocked"
1✔
1578
        incr_if_enabled(f"phones_{contact_type}_global_blocked")
1✔
1579
        setattr(relay_number, attr, getattr(relay_number, attr) + 1)
1✔
1580
        relay_number.save()
1✔
1581
        return True
1✔
1582

1583

1584
def _check_remaining(relay_number, resource_type):
1✔
1585
    # Check the owner of the relay number (still) has phone service
1586
    if not relay_number.user.profile.has_phone:
1!
1587
        raise exceptions.ValidationError("Number owner does not have phone service")
×
1588
    model_attr = f"remaining_{resource_type}"
1✔
1589
    if getattr(relay_number, model_attr) <= 0:
1✔
1590
        incr_if_enabled(f"phones_out_of_{resource_type}")
1✔
1591
        raise exceptions.ValidationError(f"Number is out of {resource_type}.")
1✔
1592
    return True
1✔
1593

1594

1595
def _get_inbound_contact(relay_number, inbound_from):
1✔
1596
    # Check if RelayNumber is storing phone log
1597
    if not relay_number.storing_phone_log:
1✔
1598
        return None
1✔
1599

1600
    # Check if RelayNumber is blocking this inbound_from
1601
    inbound_contact, _ = InboundContact.objects.get_or_create(
1✔
1602
        relay_number=relay_number, inbound_number=inbound_from
1603
    )
1604
    return inbound_contact
1✔
1605

1606

1607
def _check_and_update_contact(inbound_contact, contact_type, relay_number):
1✔
1608
    if inbound_contact.blocked:
1✔
1609
        incr_if_enabled(f"phones_{contact_type}_specific_blocked")
1✔
1610
        contact_attr = f"num_{contact_type}_blocked"
1✔
1611
        setattr(
1✔
1612
            inbound_contact, contact_attr, getattr(inbound_contact, contact_attr) + 1
1613
        )
1614
        inbound_contact.save()
1✔
1615
        relay_attr = f"{contact_type}_blocked"
1✔
1616
        setattr(relay_number, relay_attr, getattr(relay_number, relay_attr) + 1)
1✔
1617
        relay_number.save()
1✔
1618
        raise exceptions.ValidationError(f"Number is not accepting {contact_type}.")
1✔
1619

1620
    inbound_contact.last_inbound_date = datetime.now(UTC)
1✔
1621
    singular_contact_type = contact_type[:-1]  # strip trailing "s"
1✔
1622
    inbound_contact.last_inbound_type = singular_contact_type
1✔
1623
    attr = f"num_{contact_type}"
1✔
1624
    setattr(inbound_contact, attr, getattr(inbound_contact, attr) + 1)
1✔
1625
    last_date_attr = f"last_{singular_contact_type}_date"
1✔
1626
    setattr(inbound_contact, last_date_attr, inbound_contact.last_inbound_date)
1✔
1627
    inbound_contact.save()
1✔
1628

1629

1630
def _validate_twilio_request(request):
1✔
1631
    if "X-Twilio-Signature" not in request._request.headers:
1✔
1632
        raise exceptions.ValidationError(
1✔
1633
            "Invalid request: missing X-Twilio-Signature header."
1634
        )
1635

1636
    url = request._request.build_absolute_uri()
1✔
1637
    sorted_params = {}
1✔
1638
    for param_key in sorted(request.data):
1✔
1639
        sorted_params[param_key] = request.data.get(param_key)
1✔
1640
    request_signature = request._request.headers["X-Twilio-Signature"]
1✔
1641
    validator = twilio_validator()
1✔
1642
    if not validator.validate(url, sorted_params, request_signature):
1✔
1643
        incr_if_enabled("phones_invalid_twilio_signature")
1✔
1644
        raise exceptions.ValidationError("Invalid request: invalid signature")
1✔
1645

1646

1647
def compute_iq_mac(message_id: str) -> str:
1✔
1648
    iq_api_key = settings.IQ_INBOUND_API_KEY
×
1649
    # FIXME: switch to proper hmac when iQ is ready
1650
    # mac = hmac.new(
1651
    #     iq_api_key.encode(), msg=message_id.encode(), digestmod=hashlib.sha256
1652
    # )
1653
    combined = iq_api_key + message_id
×
1654
    return hashlib.sha256(combined.encode()).hexdigest()
×
1655

1656

1657
def _validate_iq_request(request: Request) -> None:
1✔
1658
    if "Verificationtoken" not in request._request.headers:
×
1659
        raise exceptions.AuthenticationFailed("missing Verificationtoken header.")
×
1660

1661
    if "MessageId" not in request._request.headers:
×
1662
        raise exceptions.AuthenticationFailed("missing MessageId header.")
×
1663

1664
    message_id = request._request.headers["Messageid"]
×
1665
    mac = compute_iq_mac(message_id)
×
1666

1667
    token = request._request.headers["verificationToken"]
×
1668

1669
    if mac != token:
×
1670
        raise exceptions.AuthenticationFailed("verificationToken != computed sha256")
×
1671

1672

1673
def convert_twilio_messages_to_dict(twilio_messages):
1✔
1674
    """
1675
    To serialize twilio messages to JSON for the API,
1676
    we need to convert them into dictionaries.
1677
    """
1678
    messages_as_dicts = []
1✔
1679
    for twilio_message in twilio_messages:
1✔
1680
        message = {}
1✔
1681
        message["from"] = twilio_message.from_
1✔
1682
        message["to"] = twilio_message.to
1✔
1683
        message["date_sent"] = twilio_message.date_sent
1✔
1684
        message["body"] = twilio_message.body
1✔
1685
        messages_as_dicts.append(message)
1✔
1686
    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