Skip to content

E.164 phone normalization

All contact phone numbers are stored in E.164 format (+[country_code][number]). Normalization runs at every write path — API endpoints, UI components, AI tools, and Twilio sync — so the same phone always resolves to the same contact.

src/shared/utils/normalize_e164.rs provides a dual-target (server + WASM) function:

pub fn normalize_e164(raw: &str) -> Result<String, String>

Normalization rules:

InputOutput
(555) 123-4567+15551234567
5551234567+15551234567 (10-digit US → prepend +1)
15551234567+15551234567 (11-digit starting with 1 → prepend +)
+1-555-123-4567+15551234567 (strip formatting)
+442071838750+442071838750 (international, passed through)
12345Err("Cannot normalize...")

The function strips non-digit characters (except leading +), then applies US NANP detection for numbers without a + prefix. International numbers that already start with + are validated for 7–15 digit length.

Normalization is idempotent — calling it on an already-normalized E.164 string returns the same value.

Normalization is enforced at four layers:

Both create_contact_phone_api and update_contact_phone_api normalize before any database operation:

let phone_number = crate::shared::normalize_e164(&data.phone_number)
.ok()
.or_bad_request("Invalid phone number: expected 10-digit US number or E.164 format")?;

After normalization, the endpoint checks org-scoped uniqueness using the normalized value. Invalid input returns 400 Bad Request.

ContactPhoneCard and CreateContact validate client-side before submitting:

let normalized = match crate::shared::normalize_e164(&phone_number()) {
Ok(n) => n,
Err(e) => {
phone_error.set(Some((Some(id), e)));
return;
}
};

If validation fails, the error displays inline as text-xs text-destructive below the input. The form stays open so you can correct the value.

ContactPhoneCard includes a prop sync guard — it only updates the local input from server props when the card is collapsed, preventing cursor jumps during editing:

use_effect(use_reactive!(|prop_phone| {
if !*expanded.peek() {
phone_number.set(prop_phone);
}
}));

The manage_contact_phones tool uses strict normalization — invalid phones return a tool error to the assistant:

let phone_number = crate::shared::normalize_e164(&phone_number)
.map_err(AppError::InvalidInput)?;

The find_contact_by_phone tool uses tolerant normalization — it falls back to the raw input if normalization fails, so partial number searches still work:

let phone_number = crate::shared::normalize_e164(&phone_number)
.unwrap_or(phone_number);

Both SMS and call sync normalize every phone before contact resolution:

let contact_phone = crate::shared::normalize_e164(raw_phone)
.unwrap_or_else(|e| {
tracing::warn!(raw_phone = %raw, error = %e, "Failed to normalize phone");
raw_phone.clone()
});

After normalization, sync uses resolve_contact() to find or create a stub contact. Since all phones are normalized before lookup, (555) 123-4567 from one Twilio message matches 5551234567 from another — no duplicate stubs.

resolve_contact() in contact/services/resolve_contact_service.rs is the canonical entry point for stub contact creation:

pub async fn resolve_contact(
caller_phone: &str,
organization_id: Uuid,
source: &str,
) -> Result<Uuid, AppError>

It normalizes the input phone, queries for an existing contact_phone in the org, and creates a stub contact + phone record if none exists. Both Twilio SMS sync and call sync use this service instead of inline stub creation.

Migration m20260407_000000_normalize_contact_phone_e164 normalizes all existing contact_phone.phone_number values:

  1. 10-digit US numbers → prepend +1
  2. 11-digit starting with 1 → prepend +
  3. E.164 with formatting (e.g., +1-555-123-4567) → strip non-digit characters after +
  4. Unparseable rows → logged via RAISE NOTICE for manual review

The migration is idempotent and one-way — original formatting can’t be reconstructed. down() is a no-op.

FileRole
shared/utils/normalize_e164.rsCore normalization function (dual-target)
shared/utils/mod.rsPublic export
contact/api/create_contact_phone_api.rsServer-side normalization + uniqueness check
contact/api/update_contact_phone_api.rsServer-side normalization + uniqueness check
contact/services/resolve_contact_service.rsCanonical stub creation with normalization
contact/components/contact_phone_card_component.rsClient-side validation + prop sync guard
contact/components/create_contact_component.rsClient-side validation on create
assistant/tools/contacts/ai_manage_contact_phones_tool.rsStrict normalization for write operations
assistant/tools/contacts/ai_find_contact_by_phone_tool.rsTolerant normalization for search
twilio/api/sync_message_history_api.rsSMS sync normalization
twilio/services/sync_call_history_service.rsCall sync normalization
migration/.../m20260407_000000_normalize_contact_phone_e164.rsData migration