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.
The normalize_e164 utility
Section titled “The normalize_e164 utility”src/shared/utils/normalize_e164.rs provides a dual-target (server + WASM) function:
pub fn normalize_e164(raw: &str) -> Result<String, String>Normalization rules:
| Input | Output |
|---|---|
(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) |
12345 | Err("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.
Where normalization runs
Section titled “Where normalization runs”Normalization is enforced at four layers:
1. API endpoints
Section titled “1. API endpoints”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.
2. UI components
Section titled “2. UI components”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); }}));3. AI tools
Section titled “3. AI tools”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);4. Twilio sync
Section titled “4. Twilio sync”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.
Contact resolution service
Section titled “Contact resolution service”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.
Database migration
Section titled “Database migration”Migration m20260407_000000_normalize_contact_phone_e164 normalizes all existing contact_phone.phone_number values:
- 10-digit US numbers → prepend
+1 - 11-digit starting with
1→ prepend+ - E.164 with formatting (e.g.,
+1-555-123-4567) → strip non-digit characters after+ - Unparseable rows → logged via
RAISE NOTICEfor manual review
The migration is idempotent and one-way — original formatting can’t be reconstructed. down() is a no-op.
Files involved
Section titled “Files involved”| File | Role |
|---|---|
shared/utils/normalize_e164.rs | Core normalization function (dual-target) |
shared/utils/mod.rs | Public export |
contact/api/create_contact_phone_api.rs | Server-side normalization + uniqueness check |
contact/api/update_contact_phone_api.rs | Server-side normalization + uniqueness check |
contact/services/resolve_contact_service.rs | Canonical stub creation with normalization |
contact/components/contact_phone_card_component.rs | Client-side validation + prop sync guard |
contact/components/create_contact_component.rs | Client-side validation on create |
assistant/tools/contacts/ai_manage_contact_phones_tool.rs | Strict normalization for write operations |
assistant/tools/contacts/ai_find_contact_by_phone_tool.rs | Tolerant normalization for search |
twilio/api/sync_message_history_api.rs | SMS sync normalization |
twilio/services/sync_call_history_service.rs | Call sync normalization |
migration/.../m20260407_000000_normalize_contact_phone_e164.rs | Data migration |