fix(contato): normalizar e-mail e telefone no formulário público de briefing (#31)

* fix(contato): normalizar e-mail e telefone no formulário público de briefing

SPEC.md §12.3 exige normalização de e-mail e telefone no formulário
público, mas o ContactBriefingRequest só validava, sem normalizar
(MAN-104).

E-mail: aplica trim + lowercase em prepareForValidation — evita
duplicatas triviais ("Maria@X.com" vs "maria@x.com") e mantém a
detecção de duplicidade por hash consistente.

Telefone: normaliza para o formato brasileiro legível
"(DD) 9XXXX-XXXX"/"(DD) XXXX-XXXX", extraído para o value object
App\Domain\Contact\BrazilianPhoneNumber por concentrar lógica de
decisão (contagem de dígitos, remoção de DDI 55) que merece teste
isolado. Optou-se pelo formato com máscara em vez de dígitos puros
porque o Mailable ContactBriefing e seus templates (html/text) apenas
imprimem o valor do campo "Telefone/WhatsApp" em uma tabela/lista,
sem link "tel:" nem formatação na view — "(11) 98888-7777" é o que
fica legível para quem recebe o briefing por e-mail, enquanto
"11988887777" é opaco de bater o olho.

Para não descartar informação que o destinatário precisa, o
normalizador só reformata quando a string é composta exclusivamente
por caracteres de telefone; anotações como "(WhatsApp)" ou "falar com
João" junto do número são preservadas como estão. Entradas que não
batem com 10/11 dígitos (após remover DDI) também são preservadas,
apenas com espaços internos colapsados.

Honeypot, rate limit e aceite de privacidade não foram tocados.

Cobertura: teste de unidade para o value object (formatos válidos,
DDI, anotações, formatos não reconhecidos) e teste de feature
provando que uma submissão com e-mail maiúsculo/padded e telefone
"sujo" chega normalizada nos Mailables ContactBriefing e
ContactBriefingConfirmation.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

* fix(contato): evitar fabricar DDD/9º dígito em números não brasileiros

O normalizador aceitava qualquer string de 10 ou 11 dígitos como se
fosse um telefone brasileiro, sem checar se os dois primeiros dígitos
formam um DDD plausível (11-99, nunca com zero em nenhuma posição) ou
se um número de 11 dígitos tem o 9º dígito obrigatório do celular.
Isso fazia números estrangeiros como '2025551234' (EUA) virarem
'(20) 2555-1234' — um número brasileiro plausível, porém inventado,
que destrói silenciosamente o contato real na tabela do e-mail de
briefing. Um '+55' explícito sem DDD (ex.: '+55 98888-7777') também
era lido como DDD 55 em vez de número incompleto.

Passa a validar a forma do DDD e o 9º dígito do celular antes de
formatar, preservando o texto original quando a checagem falha —
mesmo comportamento já usado para números com contagem de dígitos
fora do padrão. Mantém DDD 55 (Rio Grande do Sul) funcionando
normalmente quando o DDD é digitado de fato.

Limitação residual conhecida e aceita: DDDs americanos que colidem
estruturalmente com um DDD brasileiro válido (ex. '2125551234', área
212 de Nova York, bate com DDD 21) continuam sendo formatados como
brasileiros — não há como distinguir os dois casos só pela forma dos
dígitos. Da mesma forma, um '55' sem o '+' explícito (ex.
'55988887777') é ambiguamente tratado como DDD 55 real, já que nada
no texto indica se é código de país ou área.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

---------

Co-authored-by: manoel.neto <manoel.neto@creditas.com>
This commit is contained in:
2026-08-10 02:48:55 -03:00
committed by GitHub
parent fc3d5c444f
commit eed8240487
4 changed files with 220 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Domain\Contact;
/**
* Normalizes a raw phone/WhatsApp number typed into the public briefing
* form into a canonical, human-readable Brazilian format.
*
* The briefing e-mail simply prints the field value in a table, so the
* canonical form must stay legible for the staff member reading it
* "(11) 98888-7777" rather than an opaque "11988887777" digit string.
*/
final class BrazilianPhoneNumber
{
/**
* Formats to "(DD) 9XXXX-XXXX" for an 11-digit mobile number or
* "(DD) XXXX-XXXX" for a 10-digit landline number, stripping a
* leading "+55"/"55" country code when present.
*
* A 10- or 11-digit string is only formatted when it is structurally
* plausible as a Brazilian number: the first two digits must be a
* possible DDD (`[1-9][1-9]`, since Brazilian area codes run 11-99
* and never carry a '0' in either position), and an 11-digit number
* must additionally have a '9' as its third digit (mandatory on all
* Brazilian mobile numbers since 2012). An explicit "+55" prefix that
* leaves no digits for a DDD (e.g. "+55 98888-7777") is treated as a
* number missing its area code, not as DDD 55.
*
* When the digit count does not match either shape, or the shape
* fails the checks above (foreign numbers, partial input, extensions,
* etc.), the original text is preserved only whitespace is
* collapsed so no information the recipient might need is
* discarded or silently fabricated.
*/
public static function normalize(string $raw): string
{
$trimmed = trim($raw);
$collapsed = preg_replace('/\s+/', ' ', $trimmed) ?? $trimmed;
// Only reformat when the text is made exclusively of phone
// characters. Anything else — "(WhatsApp)", "falar com João",
// a ramal — is information the recipient needs, so it is left
// untouched rather than stripped away by the digit extraction.
if (preg_match('/^[0-9()+\-.\/ ]+$/', $collapsed) !== 1) {
return $collapsed;
}
$digits = preg_replace('/\D+/', '', $collapsed) ?? '';
if (in_array(strlen($digits), [12, 13], true) && str_starts_with($digits, '55')) {
$digits = substr($digits, 2);
} elseif (str_starts_with($digits, '55') && preg_match('/^\+\s*55\b/', $collapsed) === 1) {
// An explicit "+55" was written, but the total digit count
// never reached 12/13, meaning nothing precedes it that could
// be a DDD — e.g. "+55 98888-7777" is a mobile number missing
// its area code, not DDD 55 with a coincidentally-matching
// subscriber number. Guessing a DDD here would fabricate one.
return $collapsed;
}
return match (strlen($digits)) {
11 => self::isPlausibleDdd($digits) && $digits[2] === '9'
? sprintf('(%s) %s-%s', substr($digits, 0, 2), substr($digits, 2, 5), substr($digits, 7))
: $collapsed,
10 => self::isPlausibleDdd($digits)
? sprintf('(%s) %s-%s', substr($digits, 0, 2), substr($digits, 2, 4), substr($digits, 6))
: $collapsed,
default => $collapsed,
};
}
/**
* A Brazilian DDD (area code) runs 11-99: the first digit is never
* '0' (not a valid leading digit) and the second is never '0' either
* (no DDD like "10", "20", "30" exists). This does not check that the
* DDD is one of the officially assigned codes only that its shape
* is plausible enough to distinguish it from a foreign number.
*/
private static function isPlausibleDdd(string $digits): bool
{
return preg_match('/^[1-9][1-9]/', $digits) === 1;
}
}

View File

@@ -4,10 +4,26 @@ declare(strict_types=1);
namespace App\Http\Requests\PublicSite; namespace App\Http\Requests\PublicSite;
use App\Domain\Contact\BrazilianPhoneNumber;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
final class ContactBriefingRequest extends FormRequest final class ContactBriefingRequest extends FormRequest
{ {
/**
* Normaliza e-mail e telefone antes da validação (SPEC.md §12.3),
* mantendo o valor legível para quem recebe o briefing por e-mail.
*/
protected function prepareForValidation(): void
{
$email = $this->input('email');
$telefone = $this->input('telefone');
$this->merge([
'email' => is_string($email) ? mb_strtolower(trim($email)) : $email,
'telefone' => is_string($telefone) ? BrazilianPhoneNumber::normalize($telefone) : $telefone,
]);
}
/** /**
* @return array<string, array<int, string>> * @return array<string, array<int, string>>
*/ */

View File

@@ -89,6 +89,31 @@ class ContactBriefingTest extends TestCase
}); });
} }
public function test_submission_normalizes_email_and_phone_before_reaching_mailables(): void
{
$settings = SiteSetting::instance();
Mail::fake();
$response = $this->post(route('contact.store'), $this->validPayload([
'email' => ' Maria.Silva@EXAMPLE.com ',
'telefone' => '+55 (11) 98888-7777',
]));
$response
->assertRedirect(route('contact'))
->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail) use ($settings): bool {
return $mail->hasTo($settings->email)
&& $mail->fields['E-mail'] === 'maria.silva@example.com'
&& $mail->fields['Telefone/WhatsApp'] === '(11) 98888-7777';
});
Mail::assertQueued(ContactBriefingConfirmation::class, function (ContactBriefingConfirmation $mail): bool {
return $mail->hasTo('maria.silva@example.com');
});
}
public function test_honeypot_submission_is_dropped_without_sending_emails(): void public function test_honeypot_submission_is_dropped_without_sending_emails(): void
{ {
SiteSetting::instance(); SiteSetting::instance();

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Contact;
use App\Domain\Contact\BrazilianPhoneNumber;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class BrazilianPhoneNumberTest extends TestCase
{
/**
* @return iterable<string, array{string, string}>
*/
public static function normalizableNumbers(): iterable
{
yield 'mobile with punctuation' => ['(11) 98888-7777', '(11) 98888-7777'];
yield 'mobile digits only' => ['11988887777', '(11) 98888-7777'];
yield 'mobile with spaces and dashes' => ['11 98888 7777', '(11) 98888-7777'];
yield 'mobile with +55 country code' => ['+55 11 98888-7777', '(11) 98888-7777'];
yield 'mobile with bare 55 country code' => ['5511988887777', '(11) 98888-7777'];
yield 'landline digits only' => ['1133334444', '(11) 3333-4444'];
yield 'landline with punctuation' => ['(11) 3333-4444', '(11) 3333-4444'];
yield 'padded with surrounding whitespace' => [" 11 98888-7777 \n", '(11) 98888-7777'];
}
#[DataProvider('normalizableNumbers')]
public function test_it_normalizes_recognizable_brazilian_numbers(string $raw, string $expected): void
{
$this->assertSame($expected, BrazilianPhoneNumber::normalize($raw));
}
/**
* @return iterable<string, array{string}>
*/
public static function foreignOrImplausibleNumbers(): iterable
{
// 10-digit US number: DDD "20" has a '0' in the second position,
// which no real Brazilian area code has.
yield 'us number without country code' => ['2025551234'];
// 11-digit US number with leading '1': DDD "12" is plausible, but
// the third digit is '0', not the mandatory mobile '9'.
yield 'us number with leading 1' => ['12025551234'];
// 10-digit number with a DDD starting in '0', which cannot occur.
yield 'ten digits with leading zero ddd' => ['0212345678'];
// Explicit "+55" leaves only 9 digits behind — a mobile subscriber
// number with no area code, not DDD 55.
yield 'explicit country code missing ddd' => ['+55 98888-7777'];
}
#[DataProvider('foreignOrImplausibleNumbers')]
public function test_it_preserves_numbers_that_are_not_plausibly_brazilian(string $raw): void
{
$this->assertSame($raw, BrazilianPhoneNumber::normalize($raw));
}
public function test_it_still_formats_a_genuine_ddd_55_number(): void
{
// DDD 55 (Rio Grande do Sul) is a real area code and must not be
// confused with the "+55" country code prefix handling above.
$this->assertSame(
'(55) 98888-7777',
BrazilianPhoneNumber::normalize('(55) 98888-7777')
);
}
public function test_it_preserves_unrecognized_shapes_instead_of_discarding_information(): void
{
$this->assertSame(
'+44 20 7946 0958',
BrazilianPhoneNumber::normalize(' +44 20 7946 0958 ')
);
}
public function test_it_collapses_internal_whitespace_for_unrecognized_shapes(): void
{
$this->assertSame(
'ramal 123',
BrazilianPhoneNumber::normalize("ramal 123\n")
);
}
public function test_it_preserves_annotations_next_to_a_recognizable_number(): void
{
$this->assertSame(
'11 98888-7777 (WhatsApp)',
BrazilianPhoneNumber::normalize('11 98888-7777 (WhatsApp)')
);
}
}