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

mozilla / fx-private-relay / 85cda637-2ac9-4d08-9727-ee8e8a702875

06 Sep 2024 09:48PM CUT coverage: 85.506%. First build
85cda637-2ac9-4d08-9727-ee8e8a702875

push

circleci

jwhitlock
Handle Twilio errors when relaying messages

When a Relay user replies 'STOP', they are unsubscribed from Twilio and
we can't send them any more messages.

When a contact replies 'STOP', the Relay user can't reply to them
anymore.

There are other errors, but those haven't happened to us yet. Log as
errors.

4111 of 5264 branches covered (78.1%)

Branch coverage included in aggregate %.

133 of 134 new or added lines in 2 files covered. (99.25%)

16130 of 18408 relevant lines covered (87.62%)

10.34 hits per line

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

90.12
/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
    profile = real_phone.user.profile
1✔
629
    context = sms_exception.error_context()
1✔
630
    context["phone_provider"] = phone_provider
1✔
631
    if (fxa := profile.fxa) and profile.metrics_enabled:
1!
632
        context["fxa_id"] = fxa.uid
1✔
633
    else:
634
        context["fxa_id"] = ""
×
635
    info_logger.info(sms_exception.default_code, context)
1✔
636

637

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

648

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

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

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

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

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

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

726
    _check_remaining(relay_number, "texts")
1✔
727

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

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

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

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

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

816
    return response.Response(
1✔
817
        status=201,
818
        template_name="twiml_empty_response.xml",
819
    )
820

821

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

854
    inbound_body = request.data.get("text", None)
×
855
    inbound_from = request.data.get("from", None)
×
856
    inbound_to = request.data.get("to", None)
×
857
    if inbound_body is None or inbound_from is None or inbound_to is None:
×
858
        raise exceptions.ValidationError("Request missing from, to, or text.")
×
859

860
    from_num = phonenumbers.format_number(
×
861
        phonenumbers.parse(inbound_from, DEFAULT_REGION),
862
        phonenumbers.PhoneNumberFormat.E164,
863
    )
864
    single_num = inbound_to[0]
×
865
    relay_num = phonenumbers.format_number(
×
866
        phonenumbers.parse(single_num, DEFAULT_REGION),
867
        phonenumbers.PhoneNumberFormat.E164,
868
    )
869

870
    relay_number, real_phone = _get_phone_objects(relay_num)
×
871
    _check_remaining(relay_number, "texts")
×
872

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

890
        return response.Response(
×
891
            status=200,
892
            template_name="twiml_empty_response.xml",
893
        )
894

895
    number_disabled = _check_disabled(relay_number, "texts")
×
896
    if number_disabled:
×
897
        return response.Response(status=200)
×
898

899
    inbound_contact = _get_inbound_contact(relay_number, from_num)
×
900
    if inbound_contact:
×
901
        _check_and_update_contact(inbound_contact, "texts", relay_number)
×
902

903
    text = message_body(inbound_from, inbound_body)
×
904
    send_iq_sms(real_phone.number, relay_number.number, text)
×
905

906
    relay_number.remaining_texts -= 1
×
907
    relay_number.texts_forwarded += 1
×
908
    relay_number.save()
×
909
    return response.Response(status=200)
×
910

911

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

996
    The return value is TwilML Response XML that reports the error or instructs
997
    Twilio to connect the callers.
998
    """
999
    incr_if_enabled("phones_inbound_call")
1✔
1000
    _validate_twilio_request(request)
1✔
1001
    inbound_from = request.data.get("Caller", None)
1✔
1002
    inbound_to = request.data.get("Called", None)
1✔
1003
    if inbound_from is None or inbound_to is None:
1✔
1004
        raise exceptions.ValidationError("Call data missing Caller or Called.")
1✔
1005

1006
    relay_number, real_phone = _get_phone_objects(inbound_to)
1✔
1007
    if not real_phone.user.is_active:
1✔
1008
        return response.Response(
1✔
1009
            status=200,
1010
            template_name="twiml_empty_response.xml",
1011
        )
1012

1013
    number_disabled = _check_disabled(relay_number, "calls")
1✔
1014
    if number_disabled:
1✔
1015
        say = "Sorry, that number is not available."
1✔
1016
        return response.Response(
1✔
1017
            {"say": say}, status=200, template_name="twiml_blocked.xml"
1018
        )
1019

1020
    _check_remaining(relay_number, "seconds")
1✔
1021

1022
    inbound_contact = _get_inbound_contact(relay_number, inbound_from)
1✔
1023
    if inbound_contact:
1!
1024
        _check_and_update_contact(inbound_contact, "calls", relay_number)
1✔
1025

1026
    relay_number.calls_forwarded += 1
1✔
1027
    relay_number.save()
1✔
1028

1029
    # Note: TemplateTwiMLRenderer will render this as TwiML
1030
    incr_if_enabled("phones_outbound_call")
1✔
1031
    return response.Response(
1✔
1032
        {"inbound_from": inbound_from, "real_number": real_phone.number},
1033
        status=201,
1034
        template_name="twiml_dial.xml",
1035
    )
1036

1037

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

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

1103

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

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

1147

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

1187
    client = twilio_client()
1✔
1188

1189
    to = _validate_number(request, "to")  # Raises ValidationError on invalid number
1✔
1190
    client.calls.create(
1✔
1191
        twiml=(
1192
            f"<Response><Say>Dialing {to.national_format} ...</Say>"
1193
            f"<Dial>{to.phone_number}</Dial></Response>"
1194
        ),
1195
        to=real_phone.number,
1196
        from_=relay_number.number,
1197
    )
1198
    return response.Response(status=200)
1✔
1199

1200

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

1226
    POST params:
1227
        body: the body of the message
1228
        destination: E.164-formatted phone number
1229

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

1243
    errors = {}
1✔
1244
    body = request.data.get("body")
1✔
1245
    if not body:
1✔
1246
        errors["body"] = "A message body is required."
1✔
1247
    destination_number = request.data.get("destination")
1✔
1248
    if not destination_number:
1✔
1249
        errors["destination"] = "A destination number is required."
1✔
1250
    if errors:
1✔
1251
        return response.Response(errors, status=400)
1✔
1252

1253
    # Raises ValidationError on invalid number
1254
    to = _validate_number(request, "destination")
1✔
1255

1256
    client = twilio_client()
1✔
1257
    client.messages.create(from_=relay_number.number, body=body, to=to.phone_number)
1✔
1258
    return response.Response(status=200)
1✔
1259

1260

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

1302
    Pass ?with=<E.164> parameter to filter the messages to only the ones sent between
1303
    the phone mask and the <E.164> number.
1304

1305
    Pass ?direction=inbound|outbound to filter the messages to only the inbound or
1306
    outbound messages. If omitted, return both.
1307
    """
1308
    # TODO: Support filtering to messages for outbound-only phones.
1309
    # TODO: Show data from our own (encrypted) store, rather than from Twilio's
1310

1311
    if not flag_is_active(request, "outbound_phone"):
1✔
1312
        return response.Response(
1✔
1313
            {"detail": "Requires outbound_phone waffle flag."}, status=403
1314
        )
1315
    try:
1✔
1316
        relay_number = RelayNumber.objects.get(user=request.user)
1✔
1317
    except RelayNumber.DoesNotExist:
1✔
1318
        return response.Response({"detail": "Requires a phone mask."}, status=400)
1✔
1319

1320
    _with = request.query_params.get("with", None)
1✔
1321
    _direction = request.query_params.get("direction", None)
1✔
1322
    if _direction and _direction not in ("inbound", "outbound"):
1✔
1323
        return response.Response(
1✔
1324
            {"direction": "Invalid value, valid values are 'inbound' or 'outbound'"},
1325
            status=400,
1326
        )
1327

1328
    contact = None
1✔
1329
    if _with:
1✔
1330
        try:
1✔
1331
            contact = InboundContact.objects.get(
1✔
1332
                relay_number=relay_number, inbound_number=_with
1333
            )
1334
        except InboundContact.DoesNotExist:
1✔
1335
            return response.Response(
1✔
1336
                {"with": "No inbound contacts matching the number"}, status=400
1337
            )
1338

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

1361

1362
def _get_phone_objects(inbound_to):
1✔
1363
    # Get RelayNumber and RealPhone
1364
    try:
1✔
1365
        relay_number = RelayNumber.objects.get(number=inbound_to)
1✔
1366
        real_phone = RealPhone.objects.get(user=relay_number.user, verified=True)
1✔
1367
    except ObjectDoesNotExist:
1✔
1368
        raise exceptions.ValidationError("Could not find relay number.")
1✔
1369

1370
    return relay_number, real_phone
1✔
1371

1372

1373
def _prepare_sms_reply(
1✔
1374
    relay_number: RelayNumber, inbound_body: str
1375
) -> tuple[RelayNumber, str, str]:
1376
    incr_if_enabled("phones_handle_sms_reply")
1✔
1377
    if not relay_number.storing_phone_log:
1✔
1378
        # We do not store user's contacts in our database
1379
        raise NoPhoneLog()
1✔
1380

1381
    match = _match_senders_by_prefix(relay_number, inbound_body)
1✔
1382

1383
    # Fail if prefix match is ambiguous
1384
    if match and not match.contacts and match.match_type == "short":
1✔
1385
        raise ShortPrefixMatchesNoSenders(short_prefix=match.detected)
1✔
1386
    if match and not match.contacts and match.match_type == "full":
1✔
1387
        raise FullNumberMatchesNoSenders(full_number=match.detected)
1✔
1388
    if match and len(match.contacts) > 1:
1✔
1389
        if not match.match_type == "short":
1!
1390
            raise ValueError("match.match_type must be 'short'.")
×
1391
        raise MultipleNumberMatches(short_prefix=match.detected)
1✔
1392

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

1405
    # Fail if no last sender
1406
    if destination_number is None:
1✔
1407
        raise NoPreviousSender()
1✔
1408

1409
    # Determine the message body
1410
    if match:
1✔
1411
        body = inbound_body.removeprefix(match.prefix)
1✔
1412
    else:
1413
        body = inbound_body
1✔
1414

1415
    # Fail if the prefix matches a sender, but there is no body to send
1416
    if match and not body and match.match_type == "short":
1✔
1417
        raise NoBodyAfterShortPrefix(short_prefix=match.detected)
1✔
1418
    if match and not body and match.match_type == "full":
1✔
1419
        raise NoBodyAfterFullNumber(full_number=match.detected)
1✔
1420

1421
    return (relay_number, destination_number, body)
1✔
1422

1423

1424
@dataclass
1✔
1425
class MatchByPrefix:
1✔
1426
    """Details of parsing a text message for a prefix."""
1427

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

1437

1438
@dataclass
1✔
1439
class MatchData(MatchByPrefix):
1✔
1440
    """Details of expanding a MatchByPrefix with InboundContacts."""
1441

1442
    # The matching InboundContacts
1443
    contacts: list[InboundContact] = field(default_factory=list)
1✔
1444

1445

1446
def _match_senders_by_prefix(relay_number: RelayNumber, text: str) -> MatchData | None:
1✔
1447
    """
1448
    Match a prefix to previous InboundContact(s).
1449

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

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

1481
        match = _match_by_prefix(text, set(contacts_by_number.keys()))
1✔
1482
        if match:
1✔
1483
            return MatchData(
1✔
1484
                contacts=[contacts_by_number[num] for num in match.numbers],
1485
                **asdict(match),
1486
            )
1487
    return None
1✔
1488

1489

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

1503

1504
def _match_by_prefix(text: str, candidate_numbers: set[str]) -> MatchByPrefix | None:
1✔
1505
    """
1506
    Look for a prefix in a text message matching a set of candidate numbers.
1507

1508
    Arguments:
1509
    * A SMS text message
1510
    * A set of phone numbers in E.164 format
1511

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

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

1531
    # Does PhoneNumberMatcher detect a full number at start of message?
1532
    text_to_match = text[:msg_start]
1✔
1533
    for region_code in region_codes:
1✔
1534
        for match in phonenumbers.PhoneNumberMatcher(text_to_match, region_code):
1✔
1535
            e164 = phonenumbers.format_number(
1✔
1536
                match.number, phonenumbers.PhoneNumberFormat.E164
1537
            )
1538

1539
            # Look for end of prefix
1540
            end = match.start + len(match.raw_string)
1✔
1541
            found_one_sep = False
1✔
1542
            while True:
1✔
1543
                if end >= len(text):
1✔
1544
                    break
1✔
1545
                elif text[end].isspace():
1✔
1546
                    end += 1
1✔
1547
                elif text[end] in _SMS_SEPARATORS and not found_one_sep:
1✔
1548
                    found_one_sep = True
1✔
1549
                    end += 1
1✔
1550
                else:
1551
                    break
1✔
1552

1553
            prefix = text[:end]
1✔
1554
            if e164 in candidate_numbers:
1✔
1555
                numbers = [e164]
1✔
1556
            else:
1557
                numbers = []
1✔
1558
            return MatchByPrefix(
1✔
1559
                match_type="full", prefix=prefix, detected=e164, numbers=numbers
1560
            )
1561

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

1576
    # No prefix detected
1577
    return None
1✔
1578

1579

1580
def _check_disabled(relay_number, contact_type):
1✔
1581
    # Check if RelayNumber is disabled
1582
    if not relay_number.enabled:
1✔
1583
        attr = f"{contact_type}_blocked"
1✔
1584
        incr_if_enabled(f"phones_{contact_type}_global_blocked")
1✔
1585
        setattr(relay_number, attr, getattr(relay_number, attr) + 1)
1✔
1586
        relay_number.save()
1✔
1587
        return True
1✔
1588

1589

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

1600

1601
def _get_inbound_contact(relay_number, inbound_from):
1✔
1602
    # Check if RelayNumber is storing phone log
1603
    if not relay_number.storing_phone_log:
1✔
1604
        return None
1✔
1605

1606
    # Check if RelayNumber is blocking this inbound_from
1607
    inbound_contact, _ = InboundContact.objects.get_or_create(
1✔
1608
        relay_number=relay_number, inbound_number=inbound_from
1609
    )
1610
    return inbound_contact
1✔
1611

1612

1613
def _check_and_update_contact(inbound_contact, contact_type, relay_number):
1✔
1614
    if inbound_contact.blocked:
1✔
1615
        incr_if_enabled(f"phones_{contact_type}_specific_blocked")
1✔
1616
        contact_attr = f"num_{contact_type}_blocked"
1✔
1617
        setattr(
1✔
1618
            inbound_contact, contact_attr, getattr(inbound_contact, contact_attr) + 1
1619
        )
1620
        inbound_contact.save()
1✔
1621
        relay_attr = f"{contact_type}_blocked"
1✔
1622
        setattr(relay_number, relay_attr, getattr(relay_number, relay_attr) + 1)
1✔
1623
        relay_number.save()
1✔
1624
        raise exceptions.ValidationError(f"Number is not accepting {contact_type}.")
1✔
1625

1626
    inbound_contact.last_inbound_date = datetime.now(UTC)
1✔
1627
    singular_contact_type = contact_type[:-1]  # strip trailing "s"
1✔
1628
    inbound_contact.last_inbound_type = singular_contact_type
1✔
1629
    attr = f"num_{contact_type}"
1✔
1630
    setattr(inbound_contact, attr, getattr(inbound_contact, attr) + 1)
1✔
1631
    last_date_attr = f"last_{singular_contact_type}_date"
1✔
1632
    setattr(inbound_contact, last_date_attr, inbound_contact.last_inbound_date)
1✔
1633
    inbound_contact.save()
1✔
1634

1635

1636
def _validate_twilio_request(request):
1✔
1637
    if "X-Twilio-Signature" not in request._request.headers:
1✔
1638
        raise exceptions.ValidationError(
1✔
1639
            "Invalid request: missing X-Twilio-Signature header."
1640
        )
1641

1642
    url = request._request.build_absolute_uri()
1✔
1643
    sorted_params = {}
1✔
1644
    for param_key in sorted(request.data):
1✔
1645
        sorted_params[param_key] = request.data.get(param_key)
1✔
1646
    request_signature = request._request.headers["X-Twilio-Signature"]
1✔
1647
    validator = twilio_validator()
1✔
1648
    if not validator.validate(url, sorted_params, request_signature):
1✔
1649
        incr_if_enabled("phones_invalid_twilio_signature")
1✔
1650
        raise exceptions.ValidationError("Invalid request: invalid signature")
1✔
1651

1652

1653
def compute_iq_mac(message_id: str) -> str:
1✔
1654
    iq_api_key = settings.IQ_INBOUND_API_KEY
×
1655
    # FIXME: switch to proper hmac when iQ is ready
1656
    # mac = hmac.new(
1657
    #     iq_api_key.encode(), msg=message_id.encode(), digestmod=hashlib.sha256
1658
    # )
1659
    combined = iq_api_key + message_id
×
1660
    return hashlib.sha256(combined.encode()).hexdigest()
×
1661

1662

1663
def _validate_iq_request(request: Request) -> None:
1✔
1664
    if "Verificationtoken" not in request._request.headers:
×
1665
        raise exceptions.AuthenticationFailed("missing Verificationtoken header.")
×
1666

1667
    if "MessageId" not in request._request.headers:
×
1668
        raise exceptions.AuthenticationFailed("missing MessageId header.")
×
1669

1670
    message_id = request._request.headers["Messageid"]
×
1671
    mac = compute_iq_mac(message_id)
×
1672

1673
    token = request._request.headers["verificationToken"]
×
1674

1675
    if mac != token:
×
1676
        raise exceptions.AuthenticationFailed("verificationToken != computed sha256")
×
1677

1678

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