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

mozilla / fx-private-relay / 3e4e7d2b-92c2-41cb-82a1-5841a7a440f1

pending completion
3e4e7d2b-92c2-41cb-82a1-5841a7a440f1

push

circleci

John Whitlock
Remove extra @ sign from subdomain banner

1686 of 2560 branches covered (65.86%)

Branch coverage included in aggregate %.

5436 of 7367 relevant lines covered (73.79%)

18.66 hits per line

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

89.41
/api/views/phones.py
1
from dataclasses import asdict, dataclass, field
1✔
2
from datetime import datetime, timezone
1✔
3
import hashlib
1✔
4
import logging
1✔
5
import re
1✔
6
import string
1✔
7
from typing import Any, Literal, Optional
1✔
8

9
from waffle import get_waffle_flag_model
1✔
10
import django_ftl
1✔
11
import phonenumbers
1✔
12

13
from django.apps import apps
1✔
14
from django.conf import settings
1✔
15
from django.core.exceptions import ObjectDoesNotExist
1✔
16
from django.forms import model_to_dict
1✔
17

18
from rest_framework import (
1✔
19
    decorators,
20
    permissions,
21
    response,
22
    throttling,
23
    viewsets,
24
    exceptions,
25
)
26
from rest_framework.generics import get_object_or_404
1✔
27
from rest_framework.request import Request
1✔
28
from drf_yasg import openapi
1✔
29
from drf_yasg.utils import swagger_auto_schema
1✔
30

31
from twilio.base.exceptions import TwilioRestException
1✔
32
from waffle import flag_is_active
1✔
33

34
from api.views import SaveToRequestUser
1✔
35
from emails.utils import incr_if_enabled
1✔
36
from phones.iq_utils import send_iq_sms
1✔
37

38
from phones.models import (
1✔
39
    InboundContact,
40
    RealPhone,
41
    RelayNumber,
42
    get_last_text_sender,
43
    get_pending_unverified_realphone_records,
44
    get_valid_realphone_verification_record,
45
    get_verified_realphone_record,
46
    get_verified_realphone_records,
47
    send_welcome_message,
48
    suggested_numbers,
49
    location_numbers,
50
    area_code_numbers,
51
    twilio_client,
52
)
53
from privaterelay.ftl_bundles import main as ftl_bundle
1✔
54

55
from ..exceptions import ConflictError, ErrorContextType
1✔
56
from ..permissions import HasPhoneService
1✔
57
from ..renderers import (
1✔
58
    TemplateTwiMLRenderer,
59
    vCardRenderer,
60
)
61
from ..serializers.phones import (
1✔
62
    InboundContactSerializer,
63
    RealPhoneSerializer,
64
    RelayNumberSerializer,
65
)
66

67

68
logger = logging.getLogger("events")
1✔
69
info_logger = logging.getLogger("eventsinfo")
1✔
70

71

72
def twilio_validator():
1✔
73
    phones_config = apps.get_app_config("phones")
1✔
74
    validator = phones_config.twilio_validator
1✔
75
    return validator
1✔
76

77

78
def twiml_app():
1✔
79
    phones_config = apps.get_app_config("phones")
×
80
    return phones_config.twiml_app
×
81

82

83
class RealPhoneRateThrottle(throttling.UserRateThrottle):
1✔
84
    rate = settings.PHONE_RATE_LIMIT
1✔
85

86

87
class RealPhoneViewSet(SaveToRequestUser, viewsets.ModelViewSet):
1✔
88
    """
89
    Get real phone number records for the authenticated user.
90

91
    The authenticated user must have a subscription that grants one of the
92
    `SUBSCRIPTIONS_WITH_PHONE` capabilities.
93

94
    Client must be authenticated, and these endpoints only return data that is
95
    "owned" by the authenticated user.
96

97
    All endpoints are rate-limited to settings.PHONE_RATE_LIMIT
98
    """
99

100
    http_method_names = ["get", "post", "patch", "delete"]
1✔
101
    permission_classes = [permissions.IsAuthenticated, HasPhoneService]
1✔
102
    serializer_class = RealPhoneSerializer
1✔
103
    # TODO: this doesn't seem to e working?
104
    throttle_classes = [RealPhoneRateThrottle]
1✔
105

106
    def get_queryset(self):
1✔
107
        return RealPhone.objects.filter(user=self.request.user)
1✔
108

109
    def create(self, request):
1✔
110
        """
111
        Add real phone number to the authenticated user.
112

113
        The "flow" to verify a real phone number is:
114
        1. POST a number (Will text a verification code to the number)
115
        2a. PATCH the verification code to the realphone/{id} endpoint
116
        2b. POST the number and verification code together
117

118
        The authenticated user must have a subscription that grants one of the
119
        `SUBSCRIPTIONS_WITH_PHONE` capabilities.
120

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

126
        If the `POST` does NOT include a `verification_code` and the number is
127
        a valid (currently, US-based) number, this endpoint will text a
128
        verification code to the number.
129

130
        If the `POST` DOES include a `verification_code`, and the code matches
131
        a code already sent to the number, this endpoint will set `verified` to
132
        `True` for this number.
133

134
        [e164]: https://en.wikipedia.org/wiki/E.164
135
        """
136
        incr_if_enabled("phones_RealPhoneViewSet.create")
1✔
137
        serializer = self.get_serializer(data=request.data)
1✔
138
        serializer.is_valid(raise_exception=True)
1✔
139

140
        # Check if the request includes a valid verification_code
141
        # value, look for any un-expired record that matches both the phone
142
        # number and verification code and mark it verified.
143
        verification_code = serializer.validated_data.get("verification_code")
1✔
144
        if verification_code:
1✔
145
            valid_record = get_valid_realphone_verification_record(
1✔
146
                request.user, serializer.validated_data["number"], verification_code
147
            )
148
            if not valid_record:
1✔
149
                incr_if_enabled("phones_RealPhoneViewSet.create.invalid_verification")
1✔
150
                raise exceptions.ValidationError(
1✔
151
                    "Could not find that verification_code for user and number."
152
                    " It may have expired."
153
                )
154

155
            headers = self.get_success_headers(serializer.validated_data)
1✔
156
            verified_valid_record = valid_record.mark_verified()
1✔
157
            incr_if_enabled("phones_RealPhoneViewSet.create.mark_verified")
1✔
158
            response_data = model_to_dict(
1✔
159
                verified_valid_record,
160
                fields=[
161
                    "id",
162
                    "number",
163
                    "verification_sent_date",
164
                    "verified",
165
                    "verified_date",
166
                ],
167
            )
168
            return response.Response(response_data, status=201, headers=headers)
1✔
169

170
        # to prevent sending verification codes to verified numbers,
171
        # check if the number is already a verified number.
172
        is_verified = get_verified_realphone_record(serializer.validated_data["number"])
1✔
173
        if is_verified:
1!
174
            raise ConflictError("A verified record already exists for this number.")
×
175

176
        # to prevent abusive sending of verification messages,
177
        # check if there is an un-expired verification code for the user
178
        pending_unverified_records = get_pending_unverified_realphone_records(
1✔
179
            serializer.validated_data["number"]
180
        )
181
        if pending_unverified_records:
1✔
182
            raise ConflictError(
1✔
183
                "An unverified record already exists for this number.",
184
            )
185

186
        # We call an additional _validate_number function with the request
187
        # to try to parse the number as a local national number in the
188
        # request.country attribute
189
        valid_number = _validate_number(request)
1✔
190
        serializer.validated_data["number"] = valid_number.phone_number
1✔
191
        serializer.validated_data["country_code"] = valid_number.country_code.upper()
1✔
192

193
        self.perform_create(serializer)
1✔
194
        incr_if_enabled("phones_RealPhoneViewSet.perform_create")
1✔
195
        headers = self.get_success_headers(serializer.validated_data)
1✔
196
        response_data = serializer.data
1✔
197
        response_data["message"] = (
1✔
198
            "Sent verification code to "
199
            f"{valid_number.phone_number} "
200
            f"(country: {valid_number.country_code} "
201
            f"carrier: {valid_number.carrier})"
202
        )
203
        return response.Response(response_data, status=201, headers=headers)
1✔
204

205
    # check verification_code during partial_update to compare
206
    # the value sent in the request against the value already on the instance
207
    # TODO: this logic might be able to move "up" into the model, but it will
208
    # need some more serious refactoring of the RealPhone.save() method
209
    def partial_update(self, request, *args, **kwargs):
1✔
210
        """
211
        Update the authenticated user's real phone number.
212

213
        The authenticated user must have a subscription that grants one of the
214
        `SUBSCRIPTIONS_WITH_PHONE` capabilities.
215

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

218
        The `number` field should be in [E.164][e164] format which includes a country
219
        code.
220

221
        The `verification_code` should be the code that was texted to the
222
        number during the `POST`. If it matches, this endpoint will set
223
        `verified` to `True` for this number.
224

225
        [e164]: https://en.wikipedia.org/wiki/E.164
226
        """
227
        incr_if_enabled("phones_RealPhoneViewSet.partial_update")
1✔
228
        instance = self.get_object()
1✔
229
        if request.data["number"] != instance.number:
1✔
230
            raise exceptions.ValidationError("Invalid number for ID.")
1✔
231
        # TODO: check verification_sent_date is not "expired"?
232
        # Note: the RealPhone.save() logic should prevent expired verifications
233
        if (
1✔
234
            "verification_code" not in request.data
235
            or not request.data["verification_code"] == instance.verification_code
236
        ):
237
            raise exceptions.ValidationError(
1✔
238
                "Invalid verification_code for ID. It may have expired."
239
            )
240

241
        instance.mark_verified()
1✔
242
        incr_if_enabled("phones_RealPhoneViewSet.partial_update.mark_verified")
1✔
243
        return super().partial_update(request, *args, **kwargs)
1✔
244

245
    def destroy(self, request, *args, **kwargs):
1✔
246
        """
247
        Delete a real phone resource.
248

249
        Only **un-verified** real phone resources can be deleted.
250
        """
251
        incr_if_enabled("phones_RealPhoneViewSet.destroy")
1✔
252
        instance = self.get_object()
1✔
253
        if instance.verified:
1✔
254
            raise exceptions.ValidationError(
1✔
255
                "Only un-verified real phone resources can be deleted."
256
            )
257

258
        return super().destroy(request, *args, **kwargs)
1✔
259

260

261
class RelayNumberViewSet(SaveToRequestUser, viewsets.ModelViewSet):
1✔
262
    http_method_names = ["get", "post", "patch"]
1✔
263
    permission_classes = [permissions.IsAuthenticated, HasPhoneService]
1✔
264
    serializer_class = RelayNumberSerializer
1✔
265

266
    def get_queryset(self):
1✔
267
        return RelayNumber.objects.filter(user=self.request.user)
1✔
268

269
    def create(self, request, *args, **kwargs):
1✔
270
        """
271
        Provision a phone number with Twilio and assign to the authenticated user.
272

273
        ⚠️ **THIS WILL BUY A PHONE NUMBER** ⚠️
274
        If you have real account credentials in your `TWILIO_*` env vars, this
275
        will really provision a Twilio number to your account. You can use
276
        [Test Credentials][test-creds] to call this endpoint without making a
277
        real phone number purchase. If you do, you need to pass one of the
278
        [test phone numbers][test-numbers].
279

280
        The `number` should be in [E.164][e164] format.
281

282
        Every call or text to the relay number will be sent as a webhook to the
283
        URL configured for your `TWILIO_SMS_APPLICATION_SID`.
284

285
        [test-creds]: https://www.twilio.com/docs/iam/test-credentials
286
        [test-numbers]: https://www.twilio.com/docs/iam/test-credentials#test-incoming-phone-numbers-parameters-PhoneNumber
287
        [e164]: https://en.wikipedia.org/wiki/E.164
288
        """  # noqa: E501  # ignore long line for URL
289
        incr_if_enabled("phones_RelayNumberViewSet.create")
1✔
290
        existing_number = RelayNumber.objects.filter(user=request.user)
1✔
291
        if existing_number:
1!
292
            raise exceptions.ValidationError("User already has a RelayNumber.")
1✔
293
        return super().create(request, *args, **kwargs)
×
294

295
    def partial_update(self, request, *args, **kwargs):
1✔
296
        """
297
        Update the authenticated user's relay number.
298

299
        The authenticated user must have a subscription that grants one of the
300
        `SUBSCRIPTIONS_WITH_PHONE` capabilities.
301

302
        The `{id}` should match a previously-`POST`ed resource that belongs to
303
        the authenticated user.
304

305
        This is primarily used to toggle the `enabled` field.
306
        """
307
        incr_if_enabled("phones_RelayNumberViewSet.partial_update")
1✔
308
        return super().partial_update(request, *args, **kwargs)
1✔
309

310
    @decorators.action(detail=False)
1✔
311
    def suggestions(self, request):
1✔
312
        """
313
        Returns suggested relay numbers for the authenticated user.
314

315
        Based on the user's real number, returns available relay numbers:
316
          * `same_prefix_options`: Numbers that match as much of the user's
317
            real number as possible.
318
          * `other_areas_options`: Numbers that exactly match the user's real
319
            number, in a different area code.
320
          * `same_area_options`: Other numbers in the same area code as the user.
321
          * `random_options`: Available numbers in the user's country
322
        """
323
        incr_if_enabled("phones_RelayNumberViewSet.suggestions")
1✔
324
        numbers = suggested_numbers(request.user)
1✔
325
        return response.Response(numbers)
1✔
326

327
    @decorators.action(detail=False)
1✔
328
    def search(self, request):
1✔
329
        """
330
        Search for available numbers.
331

332
        This endpoints uses the underlying [AvailablePhoneNumbers][apn] API.
333

334
        Accepted query params:
335
          * ?location=
336
            * Will be passed to `AvailablePhoneNumbers` `in_locality` param
337
          * ?area_code=
338
            * Will be passed to `AvailablePhoneNumbers` `area_code` param
339

340
        [apn]: https://www.twilio.com/docs/phone-numbers/api/availablephonenumberlocal-resource#read-multiple-availablephonenumberlocal-resources
341
        """  # noqa: E501  # ignore long line for URL
342
        incr_if_enabled("phones_RelayNumberViewSet.search")
1✔
343
        real_phone = get_verified_realphone_records(request.user).first()
1✔
344
        if real_phone:
1✔
345
            country_code = real_phone.country_code
1✔
346
        else:
347
            country_code = "US"
1✔
348
        location = request.query_params.get("location")
1✔
349
        if location is not None:
1✔
350
            numbers = location_numbers(location, country_code)
1✔
351
            return response.Response(numbers)
1✔
352

353
        area_code = request.query_params.get("area_code")
1✔
354
        if area_code is not None:
1✔
355
            numbers = area_code_numbers(area_code, country_code)
1✔
356
            return response.Response(numbers)
1✔
357

358
        return response.Response({}, 404)
1✔
359

360

361
class InboundContactViewSet(viewsets.ModelViewSet):
1✔
362
    http_method_names = ["get", "patch"]
1✔
363
    permission_classes = [permissions.IsAuthenticated, HasPhoneService]
1✔
364
    serializer_class = InboundContactSerializer
1✔
365

366
    def get_queryset(self):
1✔
367
        request_user_relay_num = get_object_or_404(RelayNumber, user=self.request.user)
1✔
368
        return InboundContact.objects.filter(relay_number=request_user_relay_num)
1✔
369

370

371
def _validate_number(request, number_field="number"):
1✔
372
    if number_field not in request.data:
1✔
373
        raise exceptions.ValidationError({number_field: "A number is required."})
1✔
374

375
    parsed_number = _parse_number(
1✔
376
        request.data[number_field], getattr(request, "country", None)
377
    )
378
    if not parsed_number:
1✔
379
        country = None
1✔
380
        if hasattr(request, "country"):
1!
381
            country = request.country
×
382
        error_message = (
1✔
383
            "number must be in E.164 format, or in local national format of the"
384
            f" country detected: {country}"
385
        )
386
        raise exceptions.ValidationError(error_message)
1✔
387

388
    e164_number = f"+{parsed_number.country_code}{parsed_number.national_number}"
1✔
389
    number_details = _get_number_details(e164_number)
1✔
390
    if not number_details:
1✔
391
        raise exceptions.ValidationError(
1✔
392
            f"Could not get number details for {e164_number}"
393
        )
394

395
    if number_details.country_code.upper() not in settings.TWILIO_ALLOWED_COUNTRY_CODES:
1✔
396
        incr_if_enabled("phones_validate_number_unsupported_country")
1✔
397
        raise exceptions.ValidationError(
1✔
398
            "Relay Phone is currently only available for these country codes: "
399
            f"{sorted(settings.TWILIO_ALLOWED_COUNTRY_CODES)!r}. "
400
            "Your phone number country code is: "
401
            f"'{number_details.country_code.upper()}'."
402
        )
403

404
    return number_details
1✔
405

406

407
def _parse_number(number, country=None):
1✔
408
    try:
1✔
409
        # First try to parse assuming number is E.164 with country prefix
410
        return phonenumbers.parse(number)
1✔
411
    except phonenumbers.phonenumberutil.NumberParseException as e:
1✔
412
        if e.error_type == e.INVALID_COUNTRY_CODE and country is not None:
1✔
413
            try:
1✔
414
                # Try to parse, assuming number is local national format
415
                # in the detected request country
416
                return phonenumbers.parse(number, country)
1✔
417
            except Exception:
×
418
                return None
×
419
    return None
1✔
420

421

422
def _get_number_details(e164_number):
1✔
423
    incr_if_enabled("phones_get_number_details")
1✔
424
    try:
1✔
425
        client = twilio_client()
1✔
426
        return client.lookups.v1.phone_numbers(e164_number).fetch(type=["carrier"])
1✔
427
    except Exception:
1✔
428
        logger.exception(f"Could not get number details for {e164_number}")
1✔
429
        return None
1✔
430

431

432
@decorators.api_view()
1✔
433
@decorators.permission_classes([permissions.AllowAny])
1✔
434
@decorators.renderer_classes([vCardRenderer])
1✔
435
def vCard(request, lookup_key):
1✔
436
    """
437
    Get a Relay vCard. `lookup_key` should be passed in url path.
438

439
    We use this to return a vCard for a number. When we create a RelayNumber,
440
    we create a secret lookup_key and text it to the user.
441
    """
442
    incr_if_enabled("phones_vcard")
1✔
443
    if lookup_key is None:
1!
444
        return response.Response(status=404)
×
445

446
    try:
1✔
447
        relay_number = RelayNumber.objects.get(vcard_lookup_key=lookup_key)
1✔
448
    except RelayNumber.DoesNotExist:
1✔
449
        raise exceptions.NotFound()
1✔
450
    number = relay_number.number
1✔
451

452
    resp = response.Response({"number": number})
1✔
453
    resp["Content-Disposition"] = f"attachment; filename={number}.vcf"
1✔
454
    return resp
1✔
455

456

457
@decorators.api_view(["POST"])
1✔
458
@decorators.permission_classes([permissions.IsAuthenticated, HasPhoneService])
1✔
459
def resend_welcome_sms(request):
1✔
460
    """
461
    Resend the "Welcome" SMS, including vCard.
462

463
    Requires the user to be signed in and to have phone service.
464
    """
465
    incr_if_enabled("phones_resend_welcome_sms")
1✔
466
    try:
1✔
467
        relay_number = RelayNumber.objects.get(user=request.user)
1✔
468
    except RelayNumber.DoesNotExist:
×
469
        raise exceptions.NotFound()
×
470
    send_welcome_message(request.user, relay_number)
1✔
471

472
    resp = response.Response(status=201, data={"msg": "sent"})
1✔
473
    return resp
1✔
474

475

476
def _try_delete_from_twilio(message):
1✔
477
    try:
1✔
478
        message.delete()
1✔
479
    except TwilioRestException as e:
×
480
        # Raise the exception unless it's a 404 indicating the message is already gone
481
        if e.status != 404:
×
482
            raise e
×
483

484

485
def message_body(from_num, body):
1✔
486
    return f"[Relay 📲 {from_num}] {body}"
1✔
487

488

489
def _get_user_error_message(real_phone: RealPhone, sms_exception) -> Any:
1✔
490
    # Send a translated message to the user
491
    ftl_code = sms_exception.get_codes().replace("_", "-")
1✔
492
    ftl_id = f"sms-error-{ftl_code}"
1✔
493
    # log the error in English
494
    with django_ftl.override("en"):
1✔
495
        logger.exception(ftl_bundle.format(ftl_id, sms_exception.error_context()))
1✔
496
    with django_ftl.override(real_phone.user.profile.language):
1✔
497
        user_message = ftl_bundle.format(ftl_id, sms_exception.error_context())
1✔
498
    return user_message
1✔
499

500

501
@decorators.api_view(["POST"])
1✔
502
@decorators.permission_classes([permissions.AllowAny])
1✔
503
@decorators.renderer_classes([TemplateTwiMLRenderer])
1✔
504
def inbound_sms(request):
1✔
505
    incr_if_enabled("phones_inbound_sms")
1✔
506
    _validate_twilio_request(request)
1✔
507

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

512
    inbound_msg_sid = request.data.get("MessageSid", None)
513
    if inbound_msg_sid is None:
514
        raise exceptions.ValidationError("Request missing MessageSid")
515
    tasks._try_delete_from_twilio.delay(args=message, countdown=10)
516
    """
517

518
    inbound_body = request.data.get("Body", None)
1✔
519
    inbound_from = request.data.get("From", None)
1✔
520
    inbound_to = request.data.get("To", None)
1✔
521
    if inbound_body is None or inbound_from is None or inbound_to is None:
1✔
522
        raise exceptions.ValidationError("Request missing From, To, Or Body.")
1✔
523

524
    relay_number, real_phone = _get_phone_objects(inbound_to)
1✔
525
    _check_remaining(relay_number, "texts")
1✔
526

527
    if inbound_from == real_phone.number:
1✔
528
        try:
1✔
529
            relay_number, destination_number, body = _prepare_sms_reply(
1✔
530
                relay_number, inbound_body
531
            )
532
            client = twilio_client()
1✔
533
            incr_if_enabled("phones_send_sms_reply")
1✔
534
            client.messages.create(
1✔
535
                from_=relay_number.number, body=body, to=destination_number
536
            )
537
            relay_number.remaining_texts -= 1
1✔
538
            relay_number.texts_forwarded += 1
1✔
539
            relay_number.save()
1✔
540
        except RelaySMSException as sms_exception:
1✔
541
            user_error_message = _get_user_error_message(real_phone, sms_exception)
1✔
542
            twilio_client().messages.create(
1✔
543
                from_=relay_number.number, body=user_error_message, to=real_phone.number
544
            )
545

546
            # Return 400 on critical exceptions
547
            if sms_exception.critical:
1✔
548
                raise exceptions.ValidationError(
1✔
549
                    sms_exception.detail
550
                ) from sms_exception
551
        return response.Response(
1✔
552
            status=200,
553
            template_name="twiml_empty_response.xml",
554
        )
555

556
    number_disabled = _check_disabled(relay_number, "texts")
1✔
557
    if number_disabled:
1✔
558
        return response.Response(
1✔
559
            status=200,
560
            template_name="twiml_empty_response.xml",
561
        )
562
    inbound_contact = _get_inbound_contact(relay_number, inbound_from)
1✔
563
    if inbound_contact:
1✔
564
        _check_and_update_contact(inbound_contact, "texts", relay_number)
1✔
565

566
    client = twilio_client()
1✔
567
    app = twiml_app()
1✔
568
    incr_if_enabled("phones_outbound_sms")
1✔
569
    body = message_body(inbound_from, inbound_body)
1✔
570
    client.messages.create(
1✔
571
        from_=relay_number.number,
572
        body=body,
573
        status_callback=app.sms_status_callback,
574
        to=real_phone.number,
575
    )
576
    relay_number.remaining_texts -= 1
1✔
577
    relay_number.texts_forwarded += 1
1✔
578
    relay_number.save()
1✔
579
    return response.Response(
1✔
580
        status=201,
581
        template_name="twiml_empty_response.xml",
582
    )
583

584

585
@decorators.api_view(["POST"])
1✔
586
@decorators.permission_classes([permissions.AllowAny])
1✔
587
def inbound_sms_iq(request: Request) -> response.Response:
1✔
588
    incr_if_enabled("phones_inbound_sms_iq")
×
589
    _validate_iq_request(request)
×
590

591
    inbound_body = request.data.get("text", None)
×
592
    inbound_from = request.data.get("from", None)
×
593
    inbound_to = request.data.get("to", None)
×
594
    if inbound_body is None or inbound_from is None or inbound_to is None:
×
595
        raise exceptions.ValidationError("Request missing from, to, or text.")
×
596

597
    from_num = phonenumbers.format_number(
×
598
        phonenumbers.parse(inbound_from, "US"),
599
        phonenumbers.PhoneNumberFormat.E164,
600
    )
601
    single_num = inbound_to[0]
×
602
    relay_num = phonenumbers.format_number(
×
603
        phonenumbers.parse(single_num, "US"), phonenumbers.PhoneNumberFormat.E164
604
    )
605

606
    relay_number, real_phone = _get_phone_objects(relay_num)
×
607
    _check_remaining(relay_number, "texts")
×
608

609
    if from_num == real_phone.number:
×
610
        try:
×
611
            relay_number, destination_number, body = _prepare_sms_reply(
×
612
                relay_number, inbound_body
613
            )
614
            send_iq_sms(destination_number, relay_number.number, body)
×
615
            relay_number.remaining_texts -= 1
×
616
            relay_number.texts_forwarded += 1
×
617
            relay_number.save()
×
618
            incr_if_enabled("phones_send_sms_reply_iq")
×
619
        except RelaySMSException as sms_exception:
×
620
            user_error_message = _get_user_error_message(real_phone, sms_exception)
×
621
            send_iq_sms(real_phone.number, relay_number.number, user_error_message)
×
622

623
            # Return 400 on critical exceptions
624
            if sms_exception.critical:
×
625
                raise exceptions.ValidationError(
×
626
                    sms_exception.detail
627
                ) from sms_exception
628
        return response.Response(
×
629
            status=200,
630
            template_name="twiml_empty_response.xml",
631
        )
632

633
    number_disabled = _check_disabled(relay_number, "texts")
×
634
    if number_disabled:
×
635
        return response.Response(status=200)
×
636

637
    inbound_contact = _get_inbound_contact(relay_number, from_num)
×
638
    if inbound_contact:
×
639
        _check_and_update_contact(inbound_contact, "texts", relay_number)
×
640

641
    text = message_body(inbound_from, inbound_body)
×
642
    send_iq_sms(real_phone.number, relay_number.number, text)
×
643

644
    relay_number.remaining_texts -= 1
×
645
    relay_number.texts_forwarded += 1
×
646
    relay_number.save()
×
647
    return response.Response(status=200)
×
648

649

650
@decorators.api_view(["POST"])
1✔
651
@decorators.permission_classes([permissions.AllowAny])
1✔
652
@decorators.renderer_classes([TemplateTwiMLRenderer])
1✔
653
def inbound_call(request):
1✔
654
    incr_if_enabled("phones_inbound_call")
1✔
655
    _validate_twilio_request(request)
1✔
656
    inbound_from = request.data.get("Caller", None)
1✔
657
    inbound_to = request.data.get("Called", None)
1✔
658
    if inbound_from is None or inbound_to is None:
1✔
659
        raise exceptions.ValidationError("Call data missing Caller or Called.")
1✔
660

661
    relay_number, real_phone = _get_phone_objects(inbound_to)
1✔
662

663
    number_disabled = _check_disabled(relay_number, "calls")
1✔
664
    if number_disabled:
1✔
665
        say = "Sorry, that number is not available."
1✔
666
        return response.Response(
1✔
667
            {"say": say}, status=200, template_name="twiml_blocked.xml"
668
        )
669

670
    _check_remaining(relay_number, "seconds")
1✔
671

672
    inbound_contact = _get_inbound_contact(relay_number, inbound_from)
1✔
673
    if inbound_contact:
1!
674
        _check_and_update_contact(inbound_contact, "calls", relay_number)
1✔
675

676
    relay_number.calls_forwarded += 1
1✔
677
    relay_number.save()
1✔
678

679
    # Note: TemplateTwiMLRenderer will render this as TwiML
680
    incr_if_enabled("phones_outbound_call")
1✔
681
    return response.Response(
1✔
682
        {"inbound_from": inbound_from, "real_number": real_phone.number},
683
        status=201,
684
        template_name="twiml_dial.xml",
685
    )
686

687

688
@decorators.api_view(["POST"])
1✔
689
@decorators.permission_classes([permissions.AllowAny])
1✔
690
def voice_status(request):
1✔
691
    incr_if_enabled("phones_voice_status")
1✔
692
    _validate_twilio_request(request)
1✔
693
    call_sid = request.data.get("CallSid", None)
1✔
694
    called = request.data.get("Called", None)
1✔
695
    call_status = request.data.get("CallStatus", None)
1✔
696
    if call_sid is None or called is None or call_status is None:
1✔
697
        raise exceptions.ValidationError("Call data missing Called, CallStatus")
1✔
698
    if call_status != "completed":
1✔
699
        return response.Response(status=200)
1✔
700
    call_duration = request.data.get("CallDuration", None)
1✔
701
    if call_duration is None:
1✔
702
        raise exceptions.ValidationError("completed call data missing CallDuration")
1✔
703
    relay_number, _ = _get_phone_objects(called)
1✔
704
    relay_number.remaining_seconds = relay_number.remaining_seconds - int(call_duration)
1✔
705
    relay_number.save()
1✔
706
    if relay_number.remaining_seconds < 0:
1✔
707
        info_logger.info(
1✔
708
            "phone_limit_exceeded",
709
            extra={
710
                "fxa_uid": relay_number.user.profile.fxa.uid,
711
                "call_duration_in_seconds": int(call_duration),
712
                "relay_number_enabled": relay_number.enabled,
713
                "remaining_seconds": relay_number.remaining_seconds,
714
                "remaining_minutes": relay_number.remaining_minutes,
715
            },
716
        )
717
    client = twilio_client()
1✔
718
    client.calls(call_sid).delete()
1✔
719
    return response.Response(status=200)
1✔
720

721

722
@decorators.api_view(["POST"])
1✔
723
@decorators.permission_classes([permissions.AllowAny])
1✔
724
def sms_status(request):
1✔
725
    _validate_twilio_request(request)
1✔
726
    sms_status = request.data.get("SmsStatus", None)
1✔
727
    message_sid = request.data.get("MessageSid", None)
1✔
728
    if sms_status is None or message_sid is None:
1✔
729
        raise exceptions.ValidationError(
1✔
730
            "Text status data missing SmsStatus or MessageSid"
731
        )
732
    if sms_status != "delivered":
1✔
733
        return response.Response(status=200)
1✔
734
    client = twilio_client()
1✔
735
    message = client.messages(message_sid)
1✔
736
    _try_delete_from_twilio(message)
1✔
737
    return response.Response(status=200)
1✔
738

739

740
call_body = openapi.Schema(
1✔
741
    type=openapi.TYPE_OBJECT,
742
    required=["to"],
743
    properties={"to": openapi.Schema(type=openapi.TYPE_STRING)},
744
)
745

746

747
@decorators.permission_classes([permissions.IsAuthenticated, HasPhoneService])
1✔
748
@swagger_auto_schema(method="post", request_body=call_body)
1✔
749
@decorators.api_view(["POST"])
1✔
750
def outbound_call(request):
1✔
751
    """Make a call from the authenticated user's relay number."""
752
    # TODO: Create or update an OutboundContact (new model) on send, or limit
753
    # to InboundContacts.
754
    if not flag_is_active(request, "outbound_phone"):
1✔
755
        # Return Permission Denied error
756
        return response.Response(
1✔
757
            {"detail": "Requires outbound_phone waffle flag."}, status=403
758
        )
759
    try:
1✔
760
        real_phone = RealPhone.objects.get(user=request.user, verified=True)
1✔
761
    except RealPhone.DoesNotExist:
1✔
762
        return response.Response(
1✔
763
            {"detail": "Requires a verified real phone and phone mask."}, status=400
764
        )
765
    try:
1✔
766
        relay_number = RelayNumber.objects.get(user=request.user)
1✔
767
    except RelayNumber.DoesNotExist:
1✔
768
        return response.Response({"detail": "Requires a phone mask."}, status=400)
1✔
769

770
    client = twilio_client()
1✔
771

772
    to = _validate_number(request, "to")  # Raises ValidationError on invalid number
1✔
773
    client.calls.create(
1✔
774
        twiml=(
775
            f"<Response><Say>Dialing {to.national_format} ...</Say>"
776
            f"<Dial>{to.phone_number}</Dial></Response>"
777
        ),
778
        to=real_phone.number,
779
        from_=relay_number.number,
780
    )
781
    return response.Response(status=200)
1✔
782

783

784
message_request_body = openapi.Schema(
1✔
785
    type=openapi.TYPE_OBJECT,
786
    properties={
787
        "body": openapi.Schema(type=openapi.TYPE_STRING),
788
        "destination": openapi.Schema(type=openapi.TYPE_STRING),
789
    },
790
)
791

792

793
@decorators.permission_classes([permissions.IsAuthenticated, HasPhoneService])
1✔
794
@swagger_auto_schema(method="post", request_body=message_request_body)
1✔
795
@decorators.api_view(["POST"])
1✔
796
def outbound_sms(request):
1✔
797
    """
798
    Send a message from the user's relay number.
799

800
    POST params:
801
        body: the body of the message
802
        destination: E.164-formatted phone number
803

804
    """
805
    # TODO: Create or update an OutboundContact (new model) on send, or limit
806
    # to InboundContacts.
807
    # TODO: Reduce user's SMS messages for the month by one
808
    if not flag_is_active(request, "outbound_phone"):
1✔
809
        return response.Response(
1✔
810
            {"detail": "Requires outbound_phone waffle flag."}, status=403
811
        )
812
    try:
1✔
813
        relay_number = RelayNumber.objects.get(user=request.user)
1✔
814
    except RelayNumber.DoesNotExist:
1✔
815
        return response.Response({"detail": "Requires a phone mask."}, status=400)
1✔
816

817
    errors = {}
1✔
818
    body = request.data.get("body")
1✔
819
    if not body:
1✔
820
        errors["body"] = "A message body is required."
1✔
821
    destination_number = request.data.get("destination")
1✔
822
    if not destination_number:
1✔
823
        errors["destination"] = "A destination number is required."
1✔
824
    if errors:
1✔
825
        return response.Response(errors, status=400)
1✔
826

827
    # Raises ValidationError on invalid number
828
    to = _validate_number(request, "destination")
1✔
829

830
    client = twilio_client()
1✔
831
    client.messages.create(from_=relay_number.number, body=body, to=to.phone_number)
1✔
832
    return response.Response(status=200)
1✔
833

834

835
messages_query = [
1✔
836
    openapi.Parameter(
837
        "with",
838
        openapi.IN_QUERY,
839
        description="filter to messages with the given E.164 number",
840
        type=openapi.TYPE_STRING,
841
        required=False,
842
    ),
843
    openapi.Parameter(
844
        "direction",
845
        openapi.IN_QUERY,
846
        description="filter to inbound or outbound messages",
847
        type=openapi.TYPE_STRING,
848
        enum=["inbound", "outbound"],
849
        required=False,
850
    ),
851
]
852

853

854
@decorators.permission_classes([permissions.IsAuthenticated, HasPhoneService])
1✔
855
@swagger_auto_schema(method="get", manual_parameters=messages_query)
1✔
856
@decorators.api_view(["GET"])
1✔
857
def list_messages(request):
1✔
858
    """
859
    Get the user's SMS messages sent to or from the phone mask
860

861
    Pass ?with=<E.164> parameter to filter the messages to only the ones sent between
862
    the phone mask and the <E.164> number.
863

864
    Pass ?direction=inbound|outbound to filter the messages to only the inbound or
865
    outbound messages. If omitted, return both.
866
    """
867
    # TODO: Support filtering to messages for outbound-only phones.
868
    # TODO: Show data from our own (encrypted) store, rather than from Twilio's
869

870
    if not flag_is_active(request, "outbound_phone"):
1✔
871
        return response.Response(
1✔
872
            {"detail": "Requires outbound_phone waffle flag."}, status=403
873
        )
874
    try:
1✔
875
        relay_number = RelayNumber.objects.get(user=request.user)
1✔
876
    except RelayNumber.DoesNotExist:
1✔
877
        return response.Response({"detail": "Requires a phone mask."}, status=400)
1✔
878

879
    _with = request.query_params.get("with", None)
1✔
880
    _direction = request.query_params.get("direction", None)
1✔
881
    if _direction and _direction not in ("inbound", "outbound"):
1✔
882
        return response.Response(
1✔
883
            {"direction": "Invalid value, valid values are 'inbound' or 'outbound'"},
884
            status=400,
885
        )
886

887
    contact = None
1✔
888
    if _with:
1✔
889
        try:
1✔
890
            contact = InboundContact.objects.get(
1✔
891
                relay_number=relay_number, inbound_number=_with
892
            )
893
        except InboundContact.DoesNotExist:
1✔
894
            return response.Response(
1✔
895
                {"with": "No inbound contacts matching the number"}, status=400
896
            )
897

898
    data = {}
1✔
899
    client = twilio_client()
1✔
900
    if not _direction or _direction == "inbound":
1✔
901
        # Query Twilio for SMS messages to the user's phone mask
902
        params = {"to": relay_number.number}
1✔
903
        if contact:
1✔
904
            # Filter query to SMS from this contact to the phone mask
905
            params["from_"] = contact.inbound_number
1✔
906
        data["inbound_messages"] = convert_twilio_messages_to_dict(
1✔
907
            client.messages.list(**params)
908
        )
909
    if not _direction or _direction == "outbound":
1✔
910
        # Query Twilio for SMS messages from the user's phone mask
911
        params = {"from_": relay_number.number}
1✔
912
        if contact:
1✔
913
            # Filter query to SMS from the phone mask to this contact
914
            params["to"] = contact.inbound_number
1✔
915
        data["outbound_messages"] = convert_twilio_messages_to_dict(
1✔
916
            client.messages.list(**params)
917
        )
918
    return response.Response(data, status=200)
1✔
919

920

921
def _get_phone_objects(inbound_to):
1✔
922
    # Get RelayNumber and RealPhone
923
    try:
1✔
924
        relay_number = RelayNumber.objects.get(number=inbound_to)
1✔
925
        real_phone = RealPhone.objects.get(user=relay_number.user, verified=True)
1✔
926
    except ObjectDoesNotExist:
1✔
927
        raise exceptions.ValidationError("Could not find relay number.")
1✔
928

929
    return relay_number, real_phone
1✔
930

931

932
class RelaySMSException(Exception):
1✔
933
    """
934
    Base class for exceptions when handling SMS messages.
935

936
    Modeled after restframework.APIExcpetion, but without a status_code.
937
    """
938

939
    critical: bool
1✔
940
    default_code: str
1✔
941
    default_detail: Optional[str] = None
1✔
942
    default_detail_template: Optional[str] = None
1✔
943

944
    def __init__(self, critical=False, *args, **kwargs):
1✔
945
        self.critical = critical
1✔
946
        assert (
1✔
947
            self.default_detail is not None and self.default_detail_template is None
948
        ) or (self.default_detail is None and self.default_detail_template is not None)
949
        super().__init__(*args, **kwargs)
1✔
950

951
    @property
1✔
952
    def detail(self):
1✔
953
        if self.default_detail:
1✔
954
            return self.default_detail
1✔
955
        else:
956
            return self.default_detail_template.format(**self.error_context())
1✔
957

958
    def get_codes(self):
1✔
959
        return self.default_code
1✔
960

961
    def error_context(self) -> ErrorContextType:
1✔
962
        """Return context variables for client-side translation."""
963
        return {}
1✔
964

965

966
class NoPhoneLog(RelaySMSException):
1✔
967
    default_code = "no_phone_log"
1✔
968
    default_detail_template = (
1✔
969
        "To reply, you must allow Firefox Relay to keep a log of your callers"
970
        " and text senders. You can update this under “Caller and texts log” here:"
971
        "{account_settings_url}."
972
    )
973

974
    def error_context(self) -> ErrorContextType:
1✔
975
        return {
1✔
976
            "account_settings_url": f"{settings.SITE_ORIGIN or ''}/accounts/settings/"
977
        }
978

979

980
class NoPreviousSender(RelaySMSException):
1✔
981
    default_code = "no_previous_sender"
1✔
982
    default_detail = (
1✔
983
        "Message failed to send. You can only reply to phone numbers that have sent"
984
        " you a text message."
985
    )
986

987

988
class ShortPrefixException(RelaySMSException):
1✔
989
    """Base exception for short prefix exceptions"""
990

991
    def __init__(self, short_prefix: str, *args, **kwargs):
1✔
992
        self.short_prefix = short_prefix
1✔
993
        super().__init__(*args, **kwargs)
1✔
994

995
    def error_context(self) -> ErrorContextType:
1✔
996
        return {"short_prefix": self.short_prefix}
1✔
997

998

999
class FullNumberException(RelaySMSException):
1✔
1000
    """Base exception for full number exceptions"""
1001

1002
    def __init__(self, full_number: str, *args, **kwargs):
1✔
1003
        self.full_number = full_number
1✔
1004
        super().__init__(*args, **kwargs)
1✔
1005

1006
    def error_context(self) -> ErrorContextType:
1✔
1007
        return {"full_number": self.full_number}
1✔
1008

1009

1010
class ShortPrefixMatchesNoSenders(ShortPrefixException):
1✔
1011
    default_code = "short_prefix_matches_no_senders"
1✔
1012
    default_detail_template = (
1✔
1013
        "Message failed to send. There is no phone number in this thread ending"
1014
        " in {short_prefix}. Please check the number and try again."
1015
    )
1016

1017

1018
class FullNumberMatchesNoSenders(FullNumberException):
1✔
1019
    default_code = "full_number_matches_no_senders"
1✔
1020
    default_detail_template = (
1✔
1021
        "Message failed to send. There is no previous sender with the phone"
1022
        " number {full_number}. Please check the number and try again."
1023
    )
1024

1025

1026
class MultipleNumberMatches(ShortPrefixException):
1✔
1027
    default_code = "multiple_number_matches"
1✔
1028
    default_detail_template = (
1✔
1029
        "Message failed to send. There is more than one phone number in this"
1030
        " thread ending in {short_prefix}. To retry, start your message with"
1031
        " the complete number."
1032
    )
1033

1034

1035
class NoBodyAfterShortPrefix(ShortPrefixException):
1✔
1036
    default_code = "no_body_after_short_prefix"
1✔
1037
    default_detail_template = (
1✔
1038
        "Message failed to send. Please include a message after the sender identifier"
1039
        " {short_prefix}."
1040
    )
1041

1042

1043
class NoBodyAfterFullNumber(FullNumberException):
1✔
1044
    default_code = "no_body_after_full_number"
1✔
1045
    default_detail_template = (
1✔
1046
        "Message failed to send. Please include a message after the phone number"
1047
        " {full_number}."
1048
    )
1049

1050

1051
def _prepare_sms_reply(
1✔
1052
    relay_number: RelayNumber, inbound_body: str
1053
) -> tuple[RelayNumber, str, str]:
1054
    incr_if_enabled("phones_handle_sms_reply")
1✔
1055
    if not relay_number.storing_phone_log:
1✔
1056
        # We do not store user's contacts in our database
1057
        raise NoPhoneLog(critical=True)
1✔
1058

1059
    match = _match_senders_by_prefix(relay_number, inbound_body)
1✔
1060

1061
    # Fail if prefix match is ambiguous
1062
    if match and not match.contacts and match.match_type == "short":
1✔
1063
        raise ShortPrefixMatchesNoSenders(short_prefix=match.detected)
1✔
1064
    if match and not match.contacts and match.match_type == "full":
1✔
1065
        raise FullNumberMatchesNoSenders(full_number=match.detected)
1✔
1066
    if match and len(match.contacts) > 1:
1✔
1067
        assert match.match_type == "short"
1✔
1068
        raise MultipleNumberMatches(short_prefix=match.detected)
1✔
1069

1070
    # Determine the destination number
1071
    destination_number: Optional[str] = None
1✔
1072
    if match:
1✔
1073
        # Use the sender matched by the prefix
1074
        assert len(match.contacts) == 1
1✔
1075
        destination_number = match.contacts[0].inbound_number
1✔
1076
    else:
1077
        # No prefix, default to last sender if any
1078
        last_sender = get_last_text_sender(relay_number)
1✔
1079
        destination_number = getattr(last_sender, "inbound_number", None)
1✔
1080

1081
    # Fail if no last sender
1082
    if destination_number is None:
1✔
1083
        raise NoPreviousSender(critical=True)
1✔
1084

1085
    # Determine the message body
1086
    if match:
1✔
1087
        body = inbound_body.removeprefix(match.prefix)
1✔
1088
    else:
1089
        body = inbound_body
1✔
1090

1091
    # Fail if the prefix matches a sender, but there is no body to send
1092
    if match and not body and match.match_type == "short":
1✔
1093
        raise NoBodyAfterShortPrefix(short_prefix=match.detected)
1✔
1094
    if match and not body and match.match_type == "full":
1✔
1095
        raise NoBodyAfterFullNumber(full_number=match.detected)
1✔
1096

1097
    return (relay_number, destination_number, body)
1✔
1098

1099

1100
@dataclass
1✔
1101
class MatchByPrefix:
1✔
1102
    """Details of parsing a text message for a prefix."""
1103

1104
    # Was it matched by short code or full number?
1105
    match_type: Literal["short", "full"]
1✔
1106
    # The prefix portion of the text message
1107
    prefix: str
1✔
1108
    # The detected short code or full number
1109
    detected: str
1✔
1110
    # The matching numbers, as e.164 strings, empty if None
1111
    numbers: list[str] = field(default_factory=list)
1✔
1112

1113

1114
@dataclass
1✔
1115
class MatchData(MatchByPrefix):
1✔
1116
    """Details of expanding a MatchByPrefix with InboundContacts."""
1117

1118
    # The matching InboundContacts
1119
    contacts: list[InboundContact] = field(default_factory=list)
1✔
1120

1121

1122
def _match_senders_by_prefix(
1✔
1123
    relay_number: RelayNumber, text: str
1124
) -> Optional[MatchData]:
1125
    """
1126
    Match a prefix to previous InboundContact(s).
1127

1128
    If no prefix was found, returns None
1129
    If a prefix was found, a MatchData object has details and matching InboundContacts
1130
    """
1131
    multi_replies_flag, _ = get_waffle_flag_model().objects.get_or_create(
1✔
1132
        name="multi_replies",
1133
        defaults={
1134
            "note": (
1135
                "MPP-2252: Use prefix on SMS text to specify the recipient,"
1136
                " rather than default of last contact."
1137
            )
1138
        },
1139
    )
1140

1141
    if (
1✔
1142
        multi_replies_flag.is_active_for_user(relay_number.user)
1143
        or multi_replies_flag.everyone
1144
    ):
1145
        # Load all the previous contacts, collect possible countries
1146
        contacts = InboundContact.objects.filter(relay_number=relay_number).all()
1✔
1147
        contacts_by_number: dict[str, InboundContact] = {}
1✔
1148
        for contact in contacts:
1✔
1149
            pn = phonenumbers.parse(contact.inbound_number)
1✔
1150
            e164 = phonenumbers.format_number(pn, phonenumbers.PhoneNumberFormat.E164)
1✔
1151
            if e164 not in contacts_by_number:
1!
1152
                contacts_by_number[e164] = contact
1✔
1153

1154
        match = _match_by_prefix(text, set(contacts_by_number.keys()))
1✔
1155
        if match:
1✔
1156
            return MatchData(
1✔
1157
                contacts=[contacts_by_number[num] for num in match.numbers],
1158
                **asdict(match),
1159
            )
1160
    return None
1✔
1161

1162

1163
_SMS_SHORT_PREFIX_RE = re.compile(
1✔
1164
    r"""
1165
^               # Start of string
1166
\s*             # One or more spaces
1167
\d{4}           # 4 digits
1168
\s*             # Optional whitespace
1169
[:]?     # At most one separator, sync with SMS_SEPARATORS below
1170
\s*             # Trailing whitespace
1171
""",
1172
    re.VERBOSE | re.ASCII,
1173
)
1174
_SMS_SEPARATORS = set(":")  # Sync with SMS_SHORT_PREFIX_RE above
1✔
1175

1176

1177
def _match_by_prefix(text: str, candidate_numbers: set[str]) -> Optional[MatchByPrefix]:
1✔
1178
    """
1179
    Look for a prefix in a text message matching a set of candidate numbers.
1180

1181
    Arguments:
1182
    * A SMS text message
1183
    * A set of phone numbers in E.164 format
1184

1185
    Return None if no prefix was found, or MatchByPrefix with likely match(es)
1186
    """
1187
    # Gather potential region codes, needed by PhoneNumberMatcher
1188
    region_codes = set()
1✔
1189
    for candidate_number in candidate_numbers:
1✔
1190
        pn = phonenumbers.parse(candidate_number)
1✔
1191
        if pn.country_code:
1!
1192
            region_codes |= set(
1✔
1193
                phonenumbers.region_codes_for_country_code(pn.country_code)
1194
            )
1195

1196
    # Determine where the message may start
1197
    #  PhoneNumberMatcher doesn't work well with a number directly followed by text,
1198
    #  so just feed it the start of the message that _may_ be a number.
1199
    msg_start = 0
1✔
1200
    phone_characters = set(string.digits + string.punctuation + string.whitespace)
1✔
1201
    while msg_start < len(text) and text[msg_start] in phone_characters:
1✔
1202
        msg_start += 1
1✔
1203

1204
    # Does PhoneNumberMatcher detect a full number at start of message?
1205
    text_to_match = text[:msg_start]
1✔
1206
    for region_code in region_codes:
1✔
1207
        for match in phonenumbers.PhoneNumberMatcher(text_to_match, region_code):
1✔
1208
            e164 = phonenumbers.format_number(
1✔
1209
                match.number, phonenumbers.PhoneNumberFormat.E164
1210
            )
1211

1212
            # Look for end of prefix
1213
            end = match.start + len(match.raw_string)
1✔
1214
            found_one_sep = False
1✔
1215
            while True:
1✔
1216
                if end >= len(text):
1✔
1217
                    break
1✔
1218
                elif text[end].isspace():
1✔
1219
                    end += 1
1✔
1220
                elif text[end] in _SMS_SEPARATORS and not found_one_sep:
1✔
1221
                    found_one_sep = True
1✔
1222
                    end += 1
1✔
1223
                else:
1224
                    break
1✔
1225

1226
            prefix = text[:end]
1✔
1227
            if e164 in candidate_numbers:
1✔
1228
                numbers = [e164]
1✔
1229
            else:
1230
                numbers = []
1✔
1231
            return MatchByPrefix(
1✔
1232
                match_type="full", prefix=prefix, detected=e164, numbers=numbers
1233
            )
1234

1235
    # Is there a short prefix? Return all contacts whose last 4 digits match.
1236
    text_prefix_match = _SMS_SHORT_PREFIX_RE.match(text)
1✔
1237
    if text_prefix_match:
1✔
1238
        text_prefix = text_prefix_match.group(0)
1✔
1239
        digits = set(string.digits)
1✔
1240
        digit_suffix = "".join(digit for digit in text_prefix if digit in digits)
1✔
1241
        numbers = [e164 for e164 in candidate_numbers if e164[-4:] == digit_suffix]
1✔
1242
        return MatchByPrefix(
1✔
1243
            match_type="short",
1244
            prefix=text_prefix,
1245
            detected=digit_suffix,
1246
            numbers=sorted(numbers),
1247
        )
1248

1249
    # No prefix detected
1250
    return None
1✔
1251

1252

1253
def _check_disabled(relay_number, contact_type):
1✔
1254
    # Check if RelayNumber is disabled
1255
    if not relay_number.enabled:
1✔
1256
        attr = f"{contact_type}_blocked"
1✔
1257
        incr_if_enabled(f"phones_{contact_type}_global_blocked")
1✔
1258
        setattr(relay_number, attr, getattr(relay_number, attr) + 1)
1✔
1259
        relay_number.save()
1✔
1260
        return True
1✔
1261

1262

1263
def _check_remaining(relay_number, resource_type):
1✔
1264
    # Check the owner of the relay number (still) has phone service
1265
    if not relay_number.user.profile.has_phone:
1!
1266
        raise exceptions.ValidationError("Number owner does not have phone service")
×
1267
    model_attr = f"remaining_{resource_type}"
1✔
1268
    if getattr(relay_number, model_attr) <= 0:
1✔
1269
        incr_if_enabled(f"phones_out_of_{resource_type}")
1✔
1270
        raise exceptions.ValidationError(f"Number is out of {resource_type}.")
1✔
1271
    return True
1✔
1272

1273

1274
def _get_inbound_contact(relay_number, inbound_from):
1✔
1275
    # Check if RelayNumber is storing phone log
1276
    if not relay_number.storing_phone_log:
1✔
1277
        return None
1✔
1278

1279
    # Check if RelayNumber is blocking this inbound_from
1280
    inbound_contact, _ = InboundContact.objects.get_or_create(
1✔
1281
        relay_number=relay_number, inbound_number=inbound_from
1282
    )
1283
    return inbound_contact
1✔
1284

1285

1286
def _check_and_update_contact(inbound_contact, contact_type, relay_number):
1✔
1287
    if inbound_contact.blocked:
1✔
1288
        incr_if_enabled(f"phones_{contact_type}_specific_blocked")
1✔
1289
        contact_attr = f"num_{contact_type}_blocked"
1✔
1290
        setattr(
1✔
1291
            inbound_contact, contact_attr, getattr(inbound_contact, contact_attr) + 1
1292
        )
1293
        inbound_contact.save()
1✔
1294
        relay_attr = f"{contact_type}_blocked"
1✔
1295
        setattr(relay_number, relay_attr, getattr(relay_number, relay_attr) + 1)
1✔
1296
        relay_number.save()
1✔
1297
        raise exceptions.ValidationError(f"Number is not accepting {contact_type}.")
1✔
1298

1299
    inbound_contact.last_inbound_date = datetime.now(timezone.utc)
1✔
1300
    singular_contact_type = contact_type[:-1]  # strip trailing "s"
1✔
1301
    inbound_contact.last_inbound_type = singular_contact_type
1✔
1302
    attr = f"num_{contact_type}"
1✔
1303
    setattr(inbound_contact, attr, getattr(inbound_contact, attr) + 1)
1✔
1304
    last_date_attr = f"last_{singular_contact_type}_date"
1✔
1305
    setattr(inbound_contact, last_date_attr, inbound_contact.last_inbound_date)
1✔
1306
    inbound_contact.save()
1✔
1307

1308

1309
def _validate_twilio_request(request):
1✔
1310
    if "X-Twilio-Signature" not in request._request.headers:
1✔
1311
        raise exceptions.ValidationError(
1✔
1312
            "Invalid request: missing X-Twilio-Signature header."
1313
        )
1314

1315
    url = request._request.build_absolute_uri()
1✔
1316
    sorted_params = {}
1✔
1317
    for param_key in sorted(request.data):
1✔
1318
        sorted_params[param_key] = request.data.get(param_key)
1✔
1319
    request_signature = request._request.headers["X-Twilio-Signature"]
1✔
1320
    validator = twilio_validator()
1✔
1321
    if not validator.validate(url, sorted_params, request_signature):
1✔
1322
        incr_if_enabled("phones_invalid_twilio_signature")
1✔
1323
        raise exceptions.ValidationError("Invalid request: invalid signature")
1✔
1324

1325

1326
def compute_iq_mac(message_id: str) -> str:
1✔
1327
    iq_api_key = settings.IQ_INBOUND_API_KEY
×
1328
    # FIXME: switch to proper hmac when iQ is ready
1329
    # mac = hmac.new(
1330
    #     iq_api_key.encode(), msg=message_id.encode(), digestmod=hashlib.sha256
1331
    # )
1332
    combined = iq_api_key + message_id
×
1333
    return hashlib.sha256(combined.encode()).hexdigest()
×
1334

1335

1336
def _validate_iq_request(request: Request) -> None:
1✔
1337
    if "Verificationtoken" not in request._request.headers:
×
1338
        raise exceptions.AuthenticationFailed("missing Verificationtoken header.")
×
1339

1340
    if "MessageId" not in request._request.headers:
×
1341
        raise exceptions.AuthenticationFailed("missing MessageId header.")
×
1342

1343
    message_id = request._request.headers["Messageid"]
×
1344
    mac = compute_iq_mac(message_id)
×
1345

1346
    token = request._request.headers["verificationToken"]
×
1347

1348
    if mac != token:
×
1349
        raise exceptions.AuthenticationFailed("verficiationToken != computed sha256")
×
1350

1351

1352
def convert_twilio_messages_to_dict(twilio_messages):
1✔
1353
    """
1354
    To serialize twilio messages to JSON for the API,
1355
    we need to convert them into dictionaries.
1356
    """
1357
    messages_as_dicts = []
1✔
1358
    for twilio_message in twilio_messages:
1✔
1359
        message = {}
1✔
1360
        message["from"] = twilio_message.from_
1✔
1361
        message["to"] = twilio_message.to
1✔
1362
        message["date_sent"] = twilio_message.date_sent
1✔
1363
        message["body"] = twilio_message.body
1✔
1364
        messages_as_dicts.append(message)
1✔
1365
    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