Modify-user` silently drops all but one local forwarding destination

SYSTEM INFORMATION
OS Ubuntu 20.04.6 LTS
Webmin 2.660
Virtualmin 8.2.0.gpl-1
Mail system Postfix (mail_system=0)

modify-user silently drops all but one local forwarding destination

Summary

Any virtualmin modify-user call on a mailbox that forwards to more than one local
mailbox
discards all but the last of those destinations — permanently, with no error and
no warning.

This happens on every modify-user invocation for such a user, including ones that have
nothing to do with mail routing (--pass, --real-name, --quota). The alias block in
modify-user.pl is entered unconditionally whenever the user has an email address, and it
rewrites the user’s forwarding list from a model that can only hold one local destination.

The equivalent operation in the web UI is not affected — it passes an argument the CLI
does not (details below).

Steps to reproduce

  1. Create a domain example.com with three mailboxes: alice, bob and archive.

  2. Create a fourth mailbox sales@example.com, and set it to forward to both
    alice@example.com and archive@example.com as local destinations (Edit Users →
    Sales → Forward to other addresses, using the local mailbox names so Virtualmin
    records them as local delivery rather than as external forwards).

  3. Confirm the routing is in place:

    # virtualmin list-users --domain example.com --user sales --simple-aliases --multiline
    
  4. Now change something unrelated to mail routing:

    # virtualmin modify-user --domain example.com --user sales --real-name "Sales Desk"
    
  5. Re-read the user’s forwarding.

Expected

sales@example.com still forwards to both alice@example.com and archive@example.com.
The command changed only the real name.

Actual

sales@example.com now forwards to archive@example.com only. alice@example.com has
been removed from the alias. The command exited 0 and printed no warning.

Mail addressed to sales@ stops reaching alice@ from that moment on. Because the change
is silent and the trigger is an unrelated field, there is nothing to connect the missing
mail to the command that caused it.

Root cause

simple-lib.pl, get_simple_alias() — local delivery destinations (atype == 7) are
stored in a scalar, so each one overwrites the previous:

elsif ($atype == 7) {
    # Local delivery
    $simple->{'local'} = $aval;
    push(@{$simple->{'local-all'}}, $aval)
        if ($allow_merge &&
            $mail_system == 0 &&
            $aval =~ /\@/ &&
            getpwnam(&unescape_user($aval)));
    }

The full set is preserved in local-all, but only when the caller passes the third
argument, allow-merging-local.

save_simple_alias() then rebuilds $alias->{'to'} from that model and emits the single
scalar:

if ($simple->{'local'} && ...) {
    push(@v, "\\".$escuser);
    }

modify-user.pl:534 calls get_simple_alias with two arguments, so $allow_merge is
false and local-all is never populated:

$simple = @{$user->{'to'}} ? &get_simple_alias($d, $user)
                           : { 'tome' => 1 };

…and reaches &save_simple_alias($d, $user, $simple) unconditionally — the enclosing
condition is only if (!$user->{'noalias'} && ($user->{'email'} || $user->{'noprimary'})),
which is true for any ordinary mailbox regardless of which flags were passed.

By contrast edit_user.cgi:373 and save_user.cgi:465,640 all pass the third argument:

$simple = &get_simple_alias($d, $user, 1);

which is why the web UI round-trips these users correctly and the CLI does not.

Impact

On one production mail server, 6 of 304 mailboxes hold more than one local destination and
would lose a recipient on any modify-user call. They are ordinary staff mailboxes
forwarding a copy to a shared archive mailbox — a common enough pattern that we expect
this is not unique to our fleet.

The severity comes from the trigger: a routine password change made through an automated
tool or a self-service panel would silently break a third party’s mail delivery, with no
signal to the operator, the mailbox owner, or the recipient who stops receiving mail.

Suggested fix

Pass the merge flag in modify-user.pl, matching the CGI callers:

$simple = @{$user->{'to'}} ? &get_simple_alias($d, $user, 1)
                           : { 'tome' => 1 };

Failing that, modify-user should refuse to proceed when get_simple_alias would collapse
multiple local destinations, rather than rewriting the alias and exiting 0.

Related read-side issue

list-users.pl prints Deliver to user and Forward, but has no output for
$simple->{'local'}. A mailbox whose only routing is local delivery to another mailbox
therefore reports:

    Deliver to user: No

with no Forward lines at all — indistinguishable from a mailbox that discards its mail.
On the same server, 17 mailboxes report this way while delivering normally. Any tool built
on list-users output will misreport them.

Hello,

Thanks for the heads-up!

@isdahlc I dug into your report and confirmed and fixed the bug.

@Jamie, it seems the bug has been around for many years.

@isdahlc Could you apply the submitted patch and try again? I expect it to work just fine for you this time:

webmin patch https://github.com/virtualmin/virtualmin-gpl/commit/4e8aa1d

Works great, thanks for the quick fix. I assume next release will have this patch included?