Repository: Mailu/Mailu

Issue:
Alternative domain of unicode domain results in "Bad adress syntax"
## Environment & Versions
### Environment
 - [x] docker-compose
 - ~~[ ] kubernetes~~
 - ~~[ ] docker swarm~~

### Versions
Version 1.9

## Description
My setup includes a domain that has Unicode characters (i.e. German umlauts), e.g. `exämple.com`. There is an alternative domain that provides a 'fallback' so users can also send emails to `example.com`. The domain has an account `mail@exämple.com` which is able to receive emails as expected. When sending an email to `mail@example.com`, the sender receives an error message provided with the Postfix error message:

```
<mail@ex??mple.com> (expanded from <mail@example.com>): bad address syntax
```

I suspect the cause might be that when looking up the alternative domain, the unicode version is provided to Postfix instead of the IDNA code (`xn--exmple-cua.com`).

It should be noted that my setup doesn't have a top-level domain with an umlaut but instead a subdomain with an umlaut (e.g. `ümlaut.example.com`). I didn't have a way to test it using a domain that contains any unicode characters itself.

## Replication Steps
1. Create a new mail domain containing an umlaut (e.g. `exämple.com`)
2. Add an account to this domain
3. Create an alternative for this domain that doesn't contain any umlaut (e.g. `example.com`)
4. Send an email to the account using the alternative domain

## Expected behaviour
The email sent to the alternative domain should be received without any error and should appear in the account's inbox (which has a primary domain with an umlaut).

## Logs

Postfix logs:

````markdown
smtp_1       | INFO:root:Connect
smtp_1       | DEBUG:root:Received bytearray(b'sendermap sender@email.com')
smtp_1       | DEBUG:root:Request sendermap/sender@email.com
smtp_1       | DEBUG:root:Table get sender@email.com
smtp_1       | DEBUG:root:Replying b'NOTFOUND '
smtp_1       | DEBUG:root:Received bytearray(b'recipientmap recipient@no-umlaut-alternative.example.com')
smtp_1       | DEBUG:root:Request recipientmap/recipient@no-umlaut-alternative.example.com
smtp_1       | DEBUG:root:Table get recipient@no-umlaut-alternative.example.com
smtp_1       | DEBUG:root:Replying b'NOTFOUND '
smtp_1       | DEBUG:root:Received bytearray(b'alias recipient@no-umlaut-alternative.example.com')
smtp_1       | DEBUG:root:Request alias/recipient@no-umlaut-alternative.example.com
smtp_1       | DEBUG:root:Table get recipient@no-umlaut-alternative.example.com
smtp_1       | DEBUG:root:Table get recipient@no-umlaut-alternative.example.com is recipient@umläut.example.com
smtp_1       | DEBUG:root:Replying b'OK recipient@uml\xc3\xa4ut.example.com'
smtp_1       | DEBUG:root:Received bytearray(b'alias recipient@uml\xc3\xa4ut.example.com')
smtp_1       | DEBUG:root:Request alias/recipient@umläut.example.com
smtp_1       | DEBUG:root:Table get recipient@umläut.example.com
smtp_1       | DEBUG:root:Table get recipient@umläut.example.com is recipient@umläut.example.com
smtp_1       | DEBUG:root:Replying b'OK recipient@uml\xc3\xa4ut.example.com'
smtp_1       | 2022-07-26T16:25:19.988453+00:00 dc2c0dc34192 postfix/cleanup[3824]: E3B23192FB03: message-id=<####@example.com>
smtp_1       | 2022-07-26T16:25:20.081615+00:00 dc2c0dc34192 postfix/qmgr[339]: E3B23192FB03: from=<sender@email.com>, size=281, nrcpt=1 (queue active)
smtp_1       | 2022-07-26T16:25:20.083187+00:00 dc2c0dc34192 postfix/smtpd[3821]: disconnect from mailu_webmail_1.mailu_default[192.168.203.7] ehlo=2 xclient=0/1 mail=1 rcpt=1 data=1 quit=1 commands=6/7
smtp_1       | 2022-07-26T16:25:20.103349+00:00 dc2c0dc34192 postfix/error[3825]: E3B23192FB03: to=<recipient@uml??ut.example.com>, orig_to=<recipient@no-umlaut-alternative.example.com>, relay=none, delay=0.36, delays=0.34/0.01/0/0.01, dsn=5.1.3, status=bounced (bad address syntax)
````

Error message provided by Postfix:

```
This is the mail system at host mail.example.com.

I'm sorry to have to inform you that your message could not
be delivered to one or more recipients. It's attached below.

For further assistance, please send mail to postmaster.

If you do so, please include this problem report. You can
delete your own text from the attached returned message.

                   The mail system

<recipient@uml??ut.example.com> (expanded from
    <[recipient@no-umlaut-alternative.example.com](mailto:recipient@no-umlaut-alternative.example.com)>): bad address syntax
Reporting-MTA: dns; mail.example.com
X-Postfix-Queue-ID: E3B23192FB03
X-Postfix-Sender: rfc822; [sender@email.com](mailto:sender@email.com)
Arrival-Date: Tue, 26 Jul 2022 16:25:19 +0000 (UTC)

Final-Recipient: rfc822; recipient@uml??ut.example.com
Original-Recipient: rfc822;[recipient@umlaut.example.com](mailto:recipient@umlaut.example.com)
Action: failed
Status: 5.1.3
Diagnostic-Code: X-Postfix; bad address syntax
```


Discussion:
This should work. IDNA encoding should already be used internally. Apparently there is an edge case.

Do you also receive this error if you send an email to the domain with the umlaut?
If you remove the alternative and send the email to the domain with the umlaut, do you still receive an error?
This can be fixed in https://github.com/Mailu/Mailu/blob/e50f6c58c0f81f639295033463584dab92f16808/core/admin/mailu/internal/views/postfix.py#L32
The return value needs to be encoded as @KingOfDog suggested.
I can prepare a PR, but I want to check the use of resolve_destination first, as it seems to return None, an address or a list of addresses - which would lead to nasty errors when joining with "," as the postfix_alias_map function does. 
https://github.com/Mailu/Mailu/blob/e50f6c58c0f81f639295033463584dab92f16808/core/admin/mailu/models.py#L436
That would be great. I've assigned it to you so it is clear you will work on this whenever you have time. This basically blocks people from using domains with a special character and alternative with normal notation.

Resolving patch:
diff --git a/core/admin/mailu/internal/views/postfix.py b/core/admin/mailu/internal/views/postfix.py
--- a/core/admin/mailu/internal/views/postfix.py
+++ b/core/admin/mailu/internal/views/postfix.py
@@ -33,8 +33,9 @@ def postfix_alias_map(alias):
     localpart, domain_name = models.Email.resolve_domain(alias)
     if localpart is None:
         return flask.jsonify(domain_name)
-    destination = models.Email.resolve_destination(localpart, domain_name)
-    return flask.jsonify(",".join(destination)) if destination else flask.abort(404)
+    if destinations := models.Email.resolve_destination(localpart, domain_name):
+        return flask.jsonify(",".join(idna_encode(destinations)))
+    return flask.abort(404)
 
 @internal.route("/postfix/transport/<path:email>")
 def postfix_transport(email):
@@ -142,9 +143,11 @@ def postfix_sender_login(sender):
     if localpart is None:
         return flask.jsonify(",".join(wildcard_senders)) if wildcard_senders else flask.abort(404)
     localpart = localpart[:next((i for i, ch in enumerate(localpart) if ch in flask.current_app.config.get('RECIPIENT_DELIMITER')), None)]
-    destination = models.Email.resolve_destination(localpart, domain_name, True)
-    destination = [*destination, *wildcard_senders] if destination else [*wildcard_senders]
-    return flask.jsonify(",".join(destination)) if destination else flask.abort(404)
+    destinations = models.Email.resolve_destination(localpart, domain_name, True) or []
+    destinations.extend(wildcard_senders)
+    if destinations:
+        return flask.jsonify(",".join(idna_encode(destinations)))
+    return flask.abort(404)
 
 @internal.route("/postfix/sender/rate/<path:sender>")
 def postfix_sender_rate(sender):
@@ -169,3 +172,11 @@ def postfix_sender_access(sender):
         except sqlalchemy.exc.StatementError:
             pass
     return flask.abort(404)
+
+# idna encode domain part of each address in list of addresses
+def idna_encode(addresses):
+    return [
+        f"{localpart}@{idna.encode(domain).decode('ascii')}"
+        for (localpart, domain) in
+        (address.rsplit("@", 1) for address in addresses)
+    ]
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
@@ -439,10 +439,15 @@ def resolve_destination(cls, localpart, domain_name, ignore_forward_keep=False):
         localpart_stripped = None
         stripped_alias = None
 
-        delim = os.environ.get('RECIPIENT_DELIMITER')
-        if delim in localpart:
-            localpart_stripped = localpart.rsplit(delim, 1)[0]
+        if delims := os.environ.get('RECIPIENT_DELIMITER'):
+            try:
+                pos = next(i for i, c in enumerate(localpart) if c in delims)
+            except StopIteration:
+                pass
+            else:
+                localpart_stripped = localpart[:pos]
 
+        # is localpart@domain_name or localpart_stripped@domain_name an user?
         user = User.query.get(f'{localpart}@{domain_name}')
         if not user and localpart_stripped:
             user = User.query.get(f'{localpart_stripped}@{domain_name}')
@@ -450,19 +455,18 @@ def resolve_destination(cls, localpart, domain_name, ignore_forward_keep=False):
         if user:
             email = f'{localpart}@{domain_name}'
 
-            if user.forward_enabled:
-                destination = user.forward_destination
-                if user.forward_keep or ignore_forward_keep:
-                    destination.append(email)
-            else:
-                destination = [email]
+            if not user.forward_enabled:
+                return [email]
 
+            destination = user.forward_destination
+            if user.forward_keep or ignore_forward_keep:
+                destination.append(email)
             return destination
 
-        pure_alias = Alias.resolve(localpart, domain_name)
-
-        if pure_alias and not pure_alias.wildcard:
-            return pure_alias.destination
+        # is localpart, domain_name or localpart_stripped@domain_name an alias?
+        if pure_alias := Alias.resolve(localpart, domain_name):
+            if not pure_alias.wildcard:
+                return pure_alias.destination
 
         if stripped_alias := Alias.resolve(localpart_stripped, domain_name):
             return stripped_alias.destination


Test patch: