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

mozilla / fx-private-relay / b7f91d7c-ed5a-454c-a2b5-bb02014a2c23

10 Sep 2024 04:52PM CUT coverage: 85.531% (+0.1%) from 85.416%
b7f91d7c-ed5a-454c-a2b5-bb02014a2c23

push

circleci

web-flow
Merge pull request #5001 from mozilla/refactor-relay-sms-exception-mpp3722

MPP-3722, MPP-3513, MPP-3890, MPP-3373: Handle more errors when relaying SMS messages

4113 of 5264 branches covered (78.13%)

Branch coverage included in aggregate %.

264 of 272 new or added lines in 11 files covered. (97.06%)

1 existing line in 1 file now uncovered.

16145 of 18421 relevant lines covered (87.64%)

10.34 hits per line

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

90.43
/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
    get_pending_unverified_realphone_records,
61
    get_valid_realphone_verification_record,
62
    get_verified_realphone_record,
63
    get_verified_realphone_records,
64
    location_numbers,
65
    send_welcome_message,
66
    suggested_numbers,
67
)
68
from privaterelay.ftl_bundles import main as ftl_bundle
1✔
69

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

89
logger = logging.getLogger("events")
1✔
90
info_logger = logging.getLogger("eventsinfo")
1✔
91

92

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

96

97
def twiml_app():
1✔
98
    return phones_config().twiml_app
×
99

100

101
class RealPhoneRateThrottle(throttling.UserRateThrottle):
1✔
102
    rate = settings.PHONE_RATE_LIMIT
1✔
103

104

105
@extend_schema(tags=["phones"])
1✔
106
class RealPhoneViewSet(SaveToRequestUser, viewsets.ModelViewSet):
1✔
107
    """
108
    Get real phone number records for the authenticated user.
109

110
    The authenticated user must have a subscription that grants one of the
111
    `SUBSCRIPTIONS_WITH_PHONE` capabilities.
112

113
    Client must be authenticated, and these endpoints only return data that is
114
    "owned" by the authenticated user.
115

116
    All endpoints are rate-limited to settings.PHONE_RATE_LIMIT
117
    """
118

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

125
    def get_queryset(self) -> QuerySet[RealPhone]:
1✔
126
        if isinstance(self.request.user, User):
1✔
127
            return RealPhone.objects.filter(user=self.request.user)
1✔
128
        return RealPhone.objects.none()
1✔
129

130
    def create(self, request):
1✔
131
        """
132
        Add real phone number to the authenticated user.
133

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

139
        The authenticated user must have a subscription that grants one of the
140
        `SUBSCRIPTIONS_WITH_PHONE` capabilities.
141

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

147
        If the `POST` does NOT include a `verification_code` and the number is
148
        a valid (currently, US-based) number, this endpoint will text a
149
        verification code to the number.
150

151
        If the `POST` DOES include a `verification_code`, and the code matches
152
        a code already sent to the number, this endpoint will set `verified` to
153
        `True` for this number.
154

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

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

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

191
        # to prevent sending verification codes to verified numbers,
192
        # check if the number is already a verified number.
193
        is_verified = get_verified_realphone_record(serializer.validated_data["number"])
1✔
194
        if is_verified:
1!
195
            raise ConflictError("A verified record already exists for this number.")
×
196

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

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

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

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

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

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

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

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

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

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

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

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

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

281

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

455
        return response.Response({}, 404)
1✔
456

457

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

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

470

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

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

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

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

504
    return number_details
1✔
505

506

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

521

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

531

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

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

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

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

576

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

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

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

608

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

617

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

621

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

633

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

644

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

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

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

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

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

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

722
    _check_remaining(relay_number, "texts")
1✔
723

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

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

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

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

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

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

813

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

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

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

862
    relay_number, real_phone = _get_phone_objects(relay_num)
×
863
    _check_remaining(relay_number, "texts")
×
864

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

UNCOV
882
        return response.Response(
×
883
            status=200,
884
            template_name="twiml_empty_response.xml",
885
        )
886

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

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

895
    text = message_body(inbound_from, inbound_body)
×
896
    send_iq_sms(real_phone.number, relay_number.number, text)
×
897

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

903

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

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

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

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

1012
    _check_remaining(relay_number, "seconds")
1✔
1013

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

1018
    relay_number.calls_forwarded += 1
1✔
1019
    relay_number.save()
1✔
1020

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

1029

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

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

1095

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

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

1139

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

1179
    client = twilio_client()
1✔
1180

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

1192

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

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

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

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

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

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

1252

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

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

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

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

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

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

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

1353

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

1362
    return relay_number, real_phone
1✔
1363

1364

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

1373
    match = _match_senders_by_prefix(relay_number, inbound_body)
1✔
1374

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

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

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

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

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

1413
    return (relay_number, destination_number, body)
1✔
1414

1415

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

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

1429

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

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

1437

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

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

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

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

1481

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

1495

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

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

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

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

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

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

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

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

1568
    # No prefix detected
1569
    return None
1✔
1570

1571

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

1581

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

1592

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

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

1604

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

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

1627

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

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

1644

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

1654

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

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

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

1665
    token = request._request.headers["verificationToken"]
×
1666

1667
    if mac != token:
×
NEW
1668
        raise exceptions.AuthenticationFailed("verificationToken != computed sha256")
×
1669

1670

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