> For the complete documentation index, see [llms.txt](https://docs.akinon.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.akinon.com/technical-guides/commerce/user-email-validation-and-normalization.md).

# User Email Validation & Normalization

This guide documents how Akinon Commerce validates and stores the user `email` field, and how email addresses are matched when a user registers, logs in, or places an order.

It exists to answer a specific, recurring question: **why can two spellings of the same address (for example `Ibrahim@example.com` and `ibrahim@example.com`, or an address that starts with the Turkish letter `İ`) end up as two separate users, or fail to sync to an external system?**

The short answer is that Commerce **stores the email exactly as it was submitted** (it does not lower‑case or otherwise normalize it on write), while **matching** an existing user is done **case‑insensitively**. Downstream systems (the `channel_app` integration library, an ERP, a marketplace) often compare email addresses **case‑sensitively**, so a difference in casing that Commerce ignores can still create a mismatch at the boundary.

***

## 1. Overview

**Key characteristics:**

* The `email` field is the standard Django `AbstractUser.email` field: `EmailField`, `max_length=254`. It is **not** database‑unique and has **no** database collation set.
* There is **no normalization on save** — no lower‑casing, no trimming, no Unicode normalization in the registration or order‑driven user‑creation path.
* Validation is the default DRF `EmailField` (RFC‑style syntax check). There is **no configurable email regex or format setting** — nothing analogous to `USER_PHONE_REGEX` / `USER_PHONE_FORMAT`.
* Existing‑user lookups are almost always **case‑insensitive** (`email__iexact`).
* The only paths that persist a lower‑cased email are the **social / SSO login providers**.

This combination — case‑preserving storage plus case‑insensitive matching — is the root of the behavior described in this guide.

***

## 2. Where the email is stored

The `User` model subclasses Django's `AbstractUser` and **does not redefine `email`**; the field is inherited as‑is:

```python
# omnishop/users/migrations/0001_initial.py
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address'))
```

Notes:

* **Not unique.** There is no unique constraint on `email` at the database level, and no later migration adds one or sets a collation.
* **No `save()` override** on `User` touches the email. The address is written exactly as received.
* Order records keep their own copy: `Order.user_email` is also a plain `EmailField` (not unique, no collation).
* The manager (`User.objects`) is built from Django's `UserManager`, so `create_user()` / `normalize_email()` technically exist — but the registration and order flows **do not call them**. They go through django‑allauth adapters and DRF serializers instead (see §3).

***

## 3. Write behavior — casing is preserved

When a user registers, or when a user is created as a side effect of an order, the email is stored **verbatim, with its original casing**.

* `RegisterSerializer.validate_email` calls the allauth adapter's `clean_email()`. Commerce **does not override** `clean_email`, so it returns the address unchanged — no lower‑casing.
* The serializer then saves `validated_data['email']` directly.
* The auto‑generated (“dummy”) username is a **case‑sensitive** SHA‑256 hash of the raw email. This means `S.malli@example.com` and `s.malli@example.com` produce **different** usernames.

**The one exception — social / SSO login.** The social‑account providers (for example the *akifast* and *turkcell* providers) force the incoming email to lower‑case before it is persisted. So an account created through social login is stored lower‑cased, while the same person registering with the email/password form is stored with whatever casing they typed.

> **There is no `.capitalize()`, `.title()`, or Turkish‑locale case change applied to the email or to the first/last name anywhere in the Commerce write path.** If you see an email arrive at an external system with an unexpected capital first letter (for example a lower‑case address whose first letter has become the Turkish `İ`), that transformation happened **outside Commerce** — typically in the storefront input, the `channel_app` integration, or the receiving system. See §6.

***

## 4. Match / lookup behavior — case‑insensitive

While storage preserves case, **looking up an existing user is case‑insensitive** in almost all Commerce flows, using Django's `email__iexact`:

* **Registration existence check** — `email_address_exists()` filters `EmailAddress` and then `User` with `email__iexact`.
* **Resolving a user from an email** — `get_user_from_email()` uses `email__iexact`; if more than one row matches it returns `None` (fail‑closed).
* **Order → user matching** — when an order is placed for a guest, the flow looks up `User.objects.guest().get(email__iexact=email)`. On a miss it **creates** the user with the **raw, original‑case** email.
* **Checkout email guard** and most service/adapter/form/filter lookups also use `email__iexact`.

**Exact‑match outliers.** A few background paths compare with a plain `email=` (case‑sensitive) instead of `iexact` — notably the subscription task that resolves a user by exact email. In those paths a casing difference *does* cause a miss.

**The asymmetry to remember:**

| Operation                               | Casing behavior                     |
| --------------------------------------- | ----------------------------------- |
| Write (register, order‑driven create)   | Preserved as submitted              |
| Write (social / SSO login)              | Lower‑cased                         |
| Existence / login / order matching      | Case‑insensitive (`email__iexact`)  |
| A few background tasks (e.g. subscribe) | Case‑sensitive (`email=`)           |
| Username (dummy) generation             | Case‑sensitive SHA‑256 of raw email |

***

## 5. The Turkish `İ` (U+0130) case

The dotted capital `İ` (Unicode `U+0130`, LATIN CAPITAL LETTER I WITH DOT ABOVE) is the reason several email incidents are specifically about Turkish input.

Two facts make it special:

1. **`İ` is a distinct code point from ASCII `I`/`i`.** An address that begins with `İ` (for example `İrem@example.com`) is a genuinely different byte sequence from `Irem@example.com` or `irem@example.com`.
2. **Case‑folding `İ` does not produce a plain ASCII `i`.** In Python's locale‑independent default casing, `"İ".lower()` returns **two** characters — `i` followed by the combining dot above `U+0307` (`"i̇"`) — not the single character `"i"`. Correspondingly `"I".lower()` is `"i"` and `"ı".upper()` is `"I"`.

Consequences:

* Because Commerce preserves case on write (§3), an address typed or auto‑capitalized as `İrem@example.com` is stored **with the `İ`** and passed on to integrations and the ERP unchanged.
* Case‑insensitive matching (`email__iexact`) relies on the **database's** case‑folding for U+0130, which is collation‑dependent and will **not** reliably fold `İ` to ASCII `i`. So `İrem@…` and `irem@…` may be treated as two different users even by the “case‑insensitive” path.
* A receiving system that validates addresses against an ASCII‑only pattern (for example an SAP/ERP endpoint) can reject `İ…@…` as an invalid email even though Commerce accepted it.

This is why an order can be created in Commerce but then fail to sync, or why the same shopper appears twice: the divergence is in the **casing of the address**, and specifically in how `İ` is handled, not in the account logic itself.

***

## 6. Behavior across layers (Commerce, channel\_app, Omnitron)

The email address crosses several systems, and they **do not all treat casing the same way**. This is the single most important thing to understand when diagnosing a mismatch.

| Layer                                  | On write                                              | On match / compare                                                                                                                                   |
| -------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Commerce** (storefront APIs)         | Preserves case (except social/SSO, which lower‑cases) | Case‑insensitive `email__iexact` (a few background tasks are case‑sensitive)                                                                         |
| **channel\_app** (integration library) | Passes the address through as received                | **Case‑sensitive** comparison when matching the Omnitron customer to the marketplace order (see `channel_app/omnitron/commands/orders/customers.py`) |
| **Omnitron / external ERP**            | Stores what it receives                               | Depends on the system; ERPs frequently apply their own ASCII/format validation and case‑sensitive matching                                           |

Because Commerce ignores casing on match but `channel_app` (and typical ERPs) do not, an address that Commerce happily treats as one user can be seen as a **new or non‑matching** customer at the integration boundary. That boundary comparison — not Commerce account creation — is where the duplicate or the failed order surfaces.

> **This layer inconsistency is documented current behavior, not a recommendation.** Aligning normalization across the layers would be a product change and is out of scope for this document.

***

## 7. Validation & settings — what does *not* exist for email

For phone numbers there are dedicated dynamic settings — `USER_PHONE_REGEX` and `USER_PHONE_FORMAT` (see [Dynamic Settings](/technical-guides/commerce/dynamic-settings.md), entries 27 and 28). **There is no email equivalent.**

* **No `USER_EMAIL_REGEX` / no email format setting.** The accepted character set is fixed to DRF's default `EmailField` syntax validation; it cannot be tightened or loosened through a dynamic setting, and there is no configurable “expected format” string for email.
* **`USER_PROFILE_VALIDATIONS` does not include an email validator.** Its allowed validator classes are only `omnishop.users.helpers.check_orders` and `omnishop.users.helpers.check_unique_phone`.
* **Profile edits cannot change the email.** `email` is a read‑only field on the profile serializer.

If a partner asks “what is the `USER_PHONE_REGEX` equivalent for email?”, the accurate answer is: **there isn't one — email uses only standard syntax validation and is neither normalized nor format‑constrained by a setting.**

***

## 8. Troubleshooting checklist

Use this when a shopper appears twice, an order does not attach to the expected account, or an order fails to sync to an ERP/marketplace with an email error.

1. **Fetch the stored customer** and read the email exactly:
   * `GET /api/v1/channel/:channel_id/customers/?email=<address>`
2. **Compare the two spellings character by character**, not by eye. Confirm whether they differ only in **case** (`S.malli@` vs `s.malli@`) or contain a **Turkish letter** — especially `İ` (`U+0130`) or dotless `ı` (`U+0131`) in the local part.
3. **Check which layer is reporting the mismatch.**
   * Commerce alone (registration/login/order matching) is case‑insensitive — a pure casing difference should *not* create a second Commerce user here.
   * If the duplicate/rejection comes from `channel_app` or the ERP, the boundary comparison is **case‑sensitive** — the casing difference Commerce ignored is what breaks the match. See §6.
4. **Check for the `İ` two‑character fold.** If matching “should” work but doesn't, remember that `İ`.lower() is `i` + `U+0307`, and the database's `iexact` folding for `U+0130` is collation‑dependent (§5).
5. **Remember the username hash.** Two case‑different emails yield two different dummy usernames, so they are genuinely distinct user rows if both were written.
6. **Fix at the data level** when needed: update the affected customer's email in Omnitron to the casing the downstream system expects, then re‑trigger the sync. There is no Commerce setting that will normalize this automatically.

***

## Related

* [Dynamic Settings](/technical-guides/commerce/dynamic-settings.md) — `USER_PHONE_REGEX` (27), `USER_PHONE_FORMAT` (28), `USER_PROFILE_VALIDATIONS` (53.1)


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.akinon.com/technical-guides/commerce/user-email-validation-and-normalization.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
