For the complete documentation index, see llms.txt. This page is also available as Markdown.

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:

# 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, 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.


  • Dynamic Settings — USER_PHONE_REGEX (27), USER_PHONE_FORMAT (28), USER_PROFILE_VALIDATIONS (53.1)

Last updated

Was this helpful?