Repository: Mailu/Mailu

Issue:
Local-parts are case-sensitive, but aliases cannot be create in a case-sensitive way
By RFC5321 2.4, mailbox names / locaparts are to be case-sensitive:

> […] The local-part of a mailbox MUST BE treated as case sensitive. 
> Therefore, SMTP implementations MUST take care to preserve the case of mailbox local-parts.  In particular, for some hosts, the user "smith" is different from the user "Smith". […]

However, aliases cannot be created in a case-sensitive manner. That means when i already have `j.doe@domain.com`, i cannot create `J.doe@domain.com` as alias. This makes it hard to catch mis-guided mails due to capitalization mistakes on user-end.

Also, the `admin` container replies with a stacktrace and `500`, which is not that nice. Here’s an example:
```
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.726925967Z [2019-01-23 18:31:18,724] ERROR in app: Exception on /ui/alias/create/domain.com [POST]
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.726948416Z Traceback (most recent call last):
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.726952189Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/engine/base.py", line 1193, in _execute_context
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.726954791Z     context)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.726957190Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/engine/default.py", line 509, in do_execute
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.726959764Z     cursor.execute(statement, parameters)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.726962040Z sqlite3.IntegrityError: UNIQUE constraint failed: alias.email
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.726964832Z 
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.726968152Z The above exception was the direct cause of the following exception:
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.726971744Z 
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.726974948Z Traceback (most recent call last):
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.726977487Z   File "/usr/lib/python3.6/site-packages/flask/app.py", line 2292, in wsgi_app
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.726979843Z     response = self.full_dispatch_request()
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.726982141Z   File "/usr/lib/python3.6/site-packages/flask/app.py", line 1815, in full_dispatch_request
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.726984964Z     rv = self.handle_user_exception(e)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.726987152Z   File "/usr/lib/python3.6/site-packages/flask/app.py", line 1718, in handle_user_exception
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.726989664Z     reraise(exc_type, exc_value, tb)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.726992130Z   File "/usr/lib/python3.6/site-packages/flask/_compat.py", line 35, in reraise
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.726994455Z     raise value
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.726997167Z   File "/usr/lib/python3.6/site-packages/flask/app.py", line 1813, in full_dispatch_request
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.726999625Z     rv = self.dispatch_request()
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727001830Z   File "/usr/lib/python3.6/site-packages/flask/app.py", line 1799, in dispatch_request
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727011281Z     return self.view_functions[rule.endpoint](**req.view_args)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727013701Z   File "/usr/lib/python3.6/site-packages/flask_login/utils.py", line 261, in decorated_view
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727016007Z     return func(*args, **kwargs)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727018140Z   File "/app/mailu/ui/access.py", line 28, in wrapper
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727020407Z     return callback(function, args, kwargs, dargs, dkwargs)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727022693Z   File "/app/mailu/ui/access.py", line 19, in callback
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727024951Z     return function(*args, **kwargs)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727027105Z   File "/app/mailu/ui/views/aliases.py", line 31, in alias_create
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727029399Z     models.db.session.commit()
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727031561Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/orm/scoping.py", line 153, in do
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727033856Z     return getattr(self.registry(), name)(*args, **kwargs)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727036052Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/orm/session.py", line 943, in commit
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727038401Z     self.transaction.commit()
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727041396Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/orm/session.py", line 467, in commit
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727043774Z     self._prepare_impl()
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727046016Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/orm/session.py", line 447, in _prepare_impl
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727048326Z     self.session.flush()
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727050503Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/orm/session.py", line 2254, in flush
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727052793Z     self._flush(objects)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727054975Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/orm/session.py", line 2381, in _flush
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727057335Z     transaction.rollback(_capture_exception=True)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727059515Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/util/langhelpers.py", line 66, in __exit__
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727061791Z     compat.reraise(exc_type, exc_value, exc_tb)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727064072Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/util/compat.py", line 249, in reraise
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727066363Z     raise value
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727068593Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/orm/session.py", line 2345, in _flush
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727070874Z     flush_context.execute()
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727073023Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/orm/unitofwork.py", line 395, in execute
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727075305Z     rec.execute(self)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727077482Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/orm/unitofwork.py", line 560, in execute
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727079759Z     uow
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727083889Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/orm/persistence.py", line 181, in save_obj
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727087126Z     mapper, table, insert)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727090230Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/orm/persistence.py", line 866, in _emit_insert_statements
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727094133Z     execute(statement, params)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727096369Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/engine/base.py", line 948, in execute
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727098656Z     return meth(self, multiparams, params)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727100835Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/sql/elements.py", line 269, in _execute_on_connection
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727103151Z     return connection._execute_clauseelement(self, multiparams, params)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727105502Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/engine/base.py", line 1060, in _execute_clauseelement
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727107808Z     compiled_sql, distilled_params
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727109958Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/engine/base.py", line 1200, in _execute_context
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727112269Z     context)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727114480Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/engine/base.py", line 1413, in _handle_dbapi_exception
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727116827Z     exc_info
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727119509Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/util/compat.py", line 265, in raise_from_cause
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727122920Z     reraise(type(exception), exception, tb=exc_tb, cause=cause)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727126337Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/util/compat.py", line 248, in reraise
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727129653Z     raise value.with_traceback(tb)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727132813Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/engine/base.py", line 1193, in _execute_context
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727135975Z     context)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727139066Z   File "/usr/lib/python3.6/site-packages/sqlalchemy/engine/default.py", line 509, in do_execute
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727142593Z     cursor.execute(statement, parameters)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727146001Z sqlalchemy.exc.IntegrityError: (sqlite3.IntegrityError) UNIQUE constraint failed: alias.email [SQL: 'INSERT INTO alias (created_at, updated_at, comment, localpart, wildcard, destination, domain_name, email) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'] [parameters: ('2019-01-23', None, '', 'J.doe', 0, 'johndoe@domain.com', 'domain.com', 'j.doe@domain.com')] (Background on this error at: http://sqlalche.me/e/gkpj)
admin_1_cfce06e98ae0 | 2019-01-23T18:31:18.727684900Z 172.31.0.12 - - [23/Jan/2019:18:31:18 +0000] "POST /ui/alias/create/domain.com HTTP/1.0" 500 291 "https://mail.domain.com/admin/ui/alias/create/domain.com" "Mozilla/5.0 (X11; Linux x86_64; rv:64.0) Gecko/20100101 Firefox/64.0"
```


Discussion:
With the update to 1.6 I also have problems with the aliases.

Sending to a normal user works case insensitive via your own domain and external mail servers.
For example: foo.bar@domain.com and Foo.Bar@domain.com reaches the one user, where foo.bar@domain.com is the account created.

Sending to an alias is case sensitive.
The created alias bar.foo@domain.com always works.
Whereas the address Bar.Foo@domain.com returns a "User doesn't exist" when sending the email via the internal server.
If the email comes via an external server, then somewhere along the way the address is converted to lower case or something else happens, in any case the email arrives at the alias in both spellings.
Further it is not possible to create both aliases, this results in an Internal Server Error 500.

For our users it is very common to write email addresses with capital letters, whereas the administrators always write the addresses in lower case.



Resolving patch:
diff --git a/core/admin/mailu/models.py b/core/admin/mailu/models.py
--- a/core/admin/mailu/models.py
+++ b/core/admin/mailu/models.py
@@ -436,20 +436,37 @@ class Alias(Base, Email):
 
     @classmethod
     def resolve(cls, localpart, domain_name):
-        return cls.query.filter(
-            sqlalchemy.and_(cls.domain_name == domain_name,
-                sqlalchemy.or_(
-                    sqlalchemy.and_(
-                        cls.wildcard == False,
-                        cls.localpart == localpart
-                    ), sqlalchemy.and_(
-                        cls.wildcard == True,
-                        sqlalchemy.bindparam("l", localpart).like(cls.localpart)
+        alias_preserve_case = cls.query.filter(
+                sqlalchemy.and_(cls.domain_name == domain_name,
+                    sqlalchemy.or_(
+                        sqlalchemy.and_(
+                            cls.wildcard == False,
+                            cls.localpart == localpart
+                        ), sqlalchemy.and_(
+                            cls.wildcard == True,
+                            sqlalchemy.bindparam("l", localpart).like(cls.localpart)
+                        )
                     )
                 )
-            )
-        ).order_by(cls.wildcard, sqlalchemy.func.char_length(cls.localpart).desc()).first()
+            ).order_by(cls.wildcard, sqlalchemy.func.char_length(cls.localpart).desc()).first()
+        if alias_preserve_case:
+            return alias_preserve_case
 
+        if localpart:
+            localpart = localpart.lower()
+        return cls.query.filter(
+                sqlalchemy.and_(cls.domain_name == domain_name,
+                    sqlalchemy.or_(
+                        sqlalchemy.and_(
+                            cls.wildcard == False,
+                            sqlalchemy.func.lower(cls.localpart) == localpart
+                        ), sqlalchemy.and_(
+                            cls.wildcard == True,
+                            sqlalchemy.bindparam("l", localpart).like(sqlalchemy.func.lower(cls.localpart))
+                        )
+                    )
+                )
+            ).order_by(cls.wildcard, sqlalchemy.func.char_length(sqlalchemy.func.lower(cls.localpart)).desc()).first()
 
 class Token(Base):
     """ A token is an application password for a given user.


Test patch: