Broken Share Checker Script - Updated for 2026

Discuss your pilot or production implementation with other Zimbra admins or our engineers.
Post Reply
User avatar
L. Mark Stone
Ambassador
Ambassador
Posts: 2934
Joined: Wed Oct 09, 2013 11:35 am
Location: Portland, Maine, US
ZCS/ZD Version: 10.1.17 Network Edition
Contact:

Broken Share Checker Script - Updated for 2026

Post by L. Mark Stone »

We do a fair number of Zimbra migrations aside from Rolling Upgrades (e.g. FOSS > NE) and have found that broken shares get in the way. Back in October of 2025, we released a script via our blog to fix such broken shares.

Since then, additional migrations have surfaced other ways shares can get corrupted, so we updated our broken share checking script and are releasing it via an MIT open source license.

The updated blog post where you can get the script is here: https://www.missioncriticalemail.com/20 ... en-shares/

Note that the script generates reports for each of the three kinds of brokenness, as well as shell scripts to clean up each type of brokenness.

We've tested with non-English character sets and the script works well.

Hope that helps,
Mark
___________________________________
L. Mark Stone
Mission Critical Email - Zimbra VAR/BSP/Training Partner https://www.missioncriticalemail.com/
AWS Certified Solutions Architect-Associate
User avatar
JDunphy
Elite member
Elite member
Posts: 1000
Joined: Fri Sep 12, 2014 11:18 pm
Location: Victoria, BC

Re: Broken Share Checker Script - Updated for 2026

Post by JDunphy »

Hi Mark,

I had not thought about this problem so the timing of your post was perfect as I'm currently updating a migration tool.

I also wanted an excuse to try Opus 4.8 since my wife has been raving about it. I don't normally use Claude Code myself, but I have to admit it was impressive. Starting with your script and a fairly small prompt, it produced an alternative implementation in just a few minutes. The generated version wasn't perfect out of the gate—it initially produced a false positive because of some special characters in one of my shares—but after a small correction it produced the same output as your original script on my test server.

One interesting side effect was that the alternative implementation also ran somewhat faster in my environment. I haven't tested it broadly enough to draw any conclusions from that, but it was an unexpected bonus.

FWIW, I initially had Gemini CLI 3.1 Pro take on the same challenge. It made a lot of grand statements about performance and simplification, but after about 45 minutes it had removed a fair bit of functionality and wasn't faster nor was it able to reproduce the output your script generated. At that point I called it off because among other things - one can only take "You are right to challenge me on that" so many times. :-) ;-) ;-)

Thanks for sharing your script. It gave me a few ideas for my migration work.

Jim

Code: Select all

#!/bin/bash
################################################################################
# Hybrid Bash/Perl Zimbra Broken Shares Scanner (MCE_OP2 Complete Clone)
#
# ARCHITECTURE (v3 - single-pass, parallel):
#   - Bash wrapper: Extracts local LDAP credentials securely.
#   - Inline Perl: Eliminates N+1 LDAP queries and subshell forks by reading
#     the entire directory into memory (hashes) for O(1) lookups.
#   - THE BIG WIN: a single `zmsoap GetFolderRequest @tr=1 @needGranteeName=1`
#     per user returns BOTH the broken mount points (inbound) AND every
#     folder's live <acl><grant> list. So the stale-ACL check needs NO
#     `zmmailbox gfg` calls at all -- the previous design spawned one JVM
#     *per folder* (~5N JVM cold-starts) and suffered batch-alignment false
#     positives. Here the grants arrive structurally nested under their own
#     folder in the XML, so there is nothing to align.
#   - The reachability probe (`gms`) is folded in too: if GetFolder succeeds
#     the mailbox is reachable; failures are classified from the error text.
#   - The per-user SOAP pass is run across a fork pool (JOBS workers), the
#     one remaining source of JVM cost. Set JOBS=N (default 6).
#
#   Net JVM cold-starts: ~7N serial  ->  N spread across JOBS workers.
#
# FUTURE ENHANCEMENT (phase 2 -- not implemented):
#   The N remaining JVM cold-starts are all in the per-user GetFolder pass
#   (zmsoap spawns a fresh JVM per invocation, ~1-2s of pure overhead each).
#   That overhead can be eliminated entirely by talking to the SOAP endpoint
#   directly with `curl` instead of `zmsoap`/`zmmailbox`:
#     1. Acquire ONE admin auth token (admin AuthRequest, port 7071).
#     2. For each user, issue GetFolderRequest via curl against the mailbox
#        SOAP service, delegating with the admin token + a target-account
#        context header (<account by="name">user@dom</account>), or a
#        per-user DelegateAuthRequest token.
#     3. Parse the same XML response exactly as today (this engine already
#        does pure-Perl XML parsing, so STEP 3 onward is unchanged).
#   curl startup is ~tens of ms vs the JVM's 1-2s, so the interrogation phase
#   becomes network-bound -- plausibly seconds rather than ~90s. The removal
#   scripts would still call zmprov/zmmailbox (run-once, operator-reviewed),
#   so only the read/scan path changes. Trade-off: more moving parts (token
#   bootstrap, TLS to :7071/:8443, auth-token refresh on long runs).
#   Deferred intentionally -- ~90s is fine for a once-per-cycle preflight.
#
# USAGE:
#   Run as zimbra user:            bash ./JAD_OP2_broken_shares_checker.sh
#   Tune worker count:    JOBS=8   bash ./JAD_OP2_broken_shares_checker.sh
################################################################################

set -euo pipefail

echo "[INFO] Extracting local Zimbra LDAP credentials..."
ZIMBRA_LDAP_PASSWORD=$(zmlocalconfig -s -m nokey zimbra_ldap_password)
LDAP_MASTER_URL=$(zmlocalconfig -s -m nokey ldap_master_url)
LDAP_BIND_DN=$(zmlocalconfig -s -m nokey zimbra_ldap_userdn)

export ZIMBRA_LDAP_PASSWORD LDAP_MASTER_URL LDAP_BIND_DN
export JOBS="${JOBS:-6}"

echo "[INFO] Handing off to Perl engine for in-memory processing (JOBS=$JOBS)..."

perl -w << 'PERL_EOF'
use strict;
use warnings;
use MIME::Base64;
use POSIX qw(strftime);
use File::Temp qw(tempdir);

$ENV{LANG}   = "en_US.UTF-8";
$ENV{LC_ALL} = "en_US.UTF-8";

my $JOBS = $ENV{JOBS} || 6;
$JOBS = 1 if $JOBS < 1;

my $timestamp = strftime "%Y%m%d-%H%M%S", localtime;

my $outbound_csv = "/tmp/broken-outbound-shares-$timestamp.csv";
my $outbound_sh  = "/tmp/remove-broken-outbound-$timestamp.sh";
my $inbound_csv  = "/tmp/broken-inbound-shares-$timestamp.csv";
my $inbound_sh   = "/tmp/remove-broken-inbound-$timestamp.sh";
my $stale_csv    = "/tmp/stale-acl-shares-$timestamp.csv";
my $stale_sh     = "/tmp/remove-stale-acl-$timestamp.sh";
my $skipped_file = "/tmp/broken-shares-skipped-accounts-$timestamp.txt";

my $ldap_url  = $ENV{LDAP_MASTER_URL};
my $bind_dn   = $ENV{LDAP_BIND_DN};
my $ldap_pass = $ENV{ZIMBRA_LDAP_PASSWORD};

# Utility: Safely escape strings for Bash single-quote execution
sub bash_escape {
    my $str = shift;
    $str = '' unless defined $str;
    $str =~ s/'/'\\''/g;
    return "'$str'";
}

# Utility: escape a value for embedding inside a "..." CSV field
sub csv {
    my $s = shift;
    $s = '' unless defined $s;
    $s =~ s/"/""/g;
    return $s;
}

# Utility: decode XML entities. Values pulled from the GetFolder SOAP response
# (absFolderPath, name, grant display) are XML-encoded -- e.g. a folder named
# "Family Activities & Events" comes back as "...&amp;...". The folderPath in
# zimbraSharedItem is the raw LDAP value, so we MUST decode the XML side or the
# stale-ACL key comparison silently mismatches and reports false positives.
# &amp; is decoded last so "&amp;lt;" -> "&lt;" rather than "<".
sub xml_unescape {
    my $s = shift;
    return $s unless defined $s;
    $s =~ s/&lt;/</g;
    $s =~ s/&gt;/>/g;
    $s =~ s/&quot;/"/g;
    $s =~ s/&apos;/'/g;
    $s =~ s/&#x([0-9a-fA-F]+);/chr(hex($1))/ge;
    $s =~ s/&#(\d+);/chr($1)/ge;
    $s =~ s/&amp;/&/g;
    return $s;
}

# ----------------------------------------------------------------------
# STEP 1: Build the global account cache and UUID->Email mapping
# ----------------------------------------------------------------------
print "[PERL] Building in-memory directory cache...\n";
my %valid_accounts;
my %user_list;
my %id_to_mail;
my %dn_to_mail; # Master map linking a DN to its true primary email

# Extract explicit primary delivery address alongside fallback aliases and IDs
my $search_cmd = qq{ldapsearch -x -H "$ldap_url" -D "$bind_dn" -w "$ldap_pass" -b "" -LLL -o ldif-wrap=no "(|(objectClass=zimbraAccount)(objectClass=zimbraDistributionList))" zimbraMailDeliveryAddress mail zimbraId zimbraMailAlias uid};
open(my $ldap_fh, '-|', $search_cmd) or die "Failed to execute ldapsearch: $!";

my $current_dn = "";
my %cache_dn_id;
my %cache_dn_mail;
my %cache_dn_aliases;
my %cache_dn_uid;
my %cache_dn_delivery;

while (my $line = <$ldap_fh>) {
    chomp $line;
    if ($line =~ /^dn:\s*(.+)$/i) {
        $current_dn = lc($1);
    } elsif ($line =~ /^zimbraId:\s*(.+)$/i) {
        $cache_dn_id{$current_dn} = lc($1);
    } elsif ($line =~ /^zimbraMailDeliveryAddress:\s*(.+)$/i) {
        $cache_dn_delivery{$current_dn} = lc($1);
    } elsif ($line =~ /^mail:\s*(.+)$/i) {
        push @{$cache_dn_mail{$current_dn}}, lc($1);
    } elsif ($line =~ /^zimbraMailAlias:\s*(.+)$/i) {
        push @{$cache_dn_aliases{$current_dn}}, lc($1);
    } elsif ($line =~ /^uid:\s*(.+)$/i) {
        push @{$cache_dn_uid{$current_dn}}, lc($1);
    }
}
close($ldap_fh);

# Process the buffered LDAP arrays to build the case-insensitive resolution map
foreach my $dn (keys %cache_dn_id) {
    my $id = $cache_dn_id{$dn};
    $valid_accounts{$id} = 1;

    # Establish the true primary email (Delivery Address > First Mail > First UID)
    my $primary_mail = $cache_dn_delivery{$dn};
    if (!$primary_mail && $cache_dn_mail{$dn} && @{$cache_dn_mail{$dn}}) {
        $primary_mail = $cache_dn_mail{$dn}[0];
    } elsif (!$primary_mail && $cache_dn_uid{$dn} && @{$cache_dn_uid{$dn}}) {
        $primary_mail = $cache_dn_uid{$dn}[0];
    }

    if ($primary_mail) {
        $user_list{$primary_mail} = 1; # Target for zmsoap GetFolder scan
        $id_to_mail{$id} = $primary_mail;
        $dn_to_mail{$dn} = $primary_mail; # Master owner mapping
    }

    # Map all emails, aliases, and uids back to the primary email
    foreach my $list_ref (\%cache_dn_mail, \%cache_dn_aliases, \%cache_dn_uid) {
        if ($list_ref->{$dn}) {
            foreach my $val (@{$list_ref->{$dn}}) {
                $valid_accounts{$val} = 1;
                $id_to_mail{$val} = $primary_mail if $primary_mail;
            }
        }
    }
}
my $acct_count = keys %valid_accounts;
print "[PERL] Cached $acct_count valid Zimbra references (UUIDs/Emails/Aliases/UIDs).\n";


# ----------------------------------------------------------------------
# STEP 2: Outbound shares (invalid recipients) + queue valid shares for
#         the stale-ACL diff. This is pure in-memory work, no JVM.
# ----------------------------------------------------------------------
print "[PERL] Scanning outbound shares (in-memory)...\n";
my $share_cmd = qq{ldapsearch -x -H "$ldap_url" -D "$bind_dn" -w "$ldap_pass" -b "" -LLL -o ldif-wrap=no "(zimbraSharedItem=*)" zimbraSharedItem};
open(my $share_fh, '-|', $share_cmd) or die "Failed to execute ldapsearch for shares: $!";

$current_dn = "";
my %dn_shares;

while (my $line = <$share_fh>) {
    chomp $line;
    if ($line =~ /^dn:\s*(.+)$/i) {
        $current_dn = lc($1);
    } elsif ($line =~ /^zimbraSharedItem(::?)\s*(.+)$/i) {
        my $is_b64 = ($1 eq '::');
        my $item = $is_b64 ? decode_base64($2) : $2;
        push @{$dn_shares{$current_dn}}, $item;
    }
}
close($share_fh);

my $outbound_broken_count = 0;
my %users_with_shares;
my @outbound_csv_data;
my @outbound_sh_data;

foreach my $dn (keys %dn_shares) {
    my $owner_email = $dn_to_mail{$dn} || next;

    foreach my $item (@{$dn_shares{$dn}}) {
        my %attrs;
        foreach my $pair (split /;/, $item) {
            next unless $pair =~ /:/;
            my ($k, $v) = split(/:/, $pair, 2);
            $attrs{$k} = $v if defined $k && defined $v;
        }

        my $raw_grantee = ($attrs{granteeName} && $attrs{granteeName} ne 'null')
                          ? $attrs{granteeName} : ($attrs{granteeId} || '');
        my $grantee = lc($raw_grantee);
        my $type = lc($attrs{granteeType} || '');

        next unless $grantee && $type =~ /^(usr|grp|account|group)$/;

        if (!$valid_accounts{$grantee}) {
            # [PART 2] Invalid Recipient Detection
            $outbound_broken_count++;
            my $folder = $attrs{folderPath} || 'UNKNOWN';
            my $rights = $attrs{rights} || '';

            push @outbound_csv_data,
                '"'.csv($owner_email).'","'.csv($folder).'","'.csv($type).'","'.csv($raw_grantee).'","'.csv($rights)."\"\n";

            push @outbound_sh_data, "# Owner: $owner_email\n";
            push @outbound_sh_data, "# Folder: $folder\n";
            push @outbound_sh_data, "# Broken grantee in cache: $raw_grantee\n";
            push @outbound_sh_data, "echo 'Removing broken outbound share: $owner_email - $folder -> $raw_grantee'\n";
            push @outbound_sh_data,
                "zmprov ma ".bash_escape($owner_email)." -zimbraSharedItem ".bash_escape($item)." || echo '  [WARN] Failed'\n\n";
        } else {
            # [PART 3 PREP] Valid recipient. Queue for live ACL diff.
            my $folder = $attrs{folderPath};
            if ($folder) {
                push @{$users_with_shares{$owner_email}}, {
                    folder       => $folder,
                    grantee_id   => lc($attrs{granteeId}   || ''),
                    grantee_name => lc($attrs{granteeName} || ''),
                    rights       => ($attrs{rights} || ''),
                    raw          => $item,
                };
            }
        }
    }
}
print "[PERL] Found $outbound_broken_count broken outbound shares.\n";


# ----------------------------------------------------------------------
# STEP 3: Single per-user GetFolderRequest pass (parallel).
#   One JVM per user yields:
#     (a) broken="1" links  -> inbound broken mount points
#     (b) <acl><grant>      -> live folder ACLs for the stale diff
#     (c) success/failure   -> reachability classification
# ----------------------------------------------------------------------
my @users = sort keys %user_list;
my $total = scalar @users;
print "[PERL] Single-pass GetFolder scan over $total mailboxes across $JOBS worker(s)...\n";

my $tmpdir = tempdir("jad_bs_XXXXXX", DIR => "/tmp", CLEANUP => 1);

# Run zmsoap GetFolder for one user. One retry on timeout at a doubled limit.
# Returns ($xml, $status) where status is ok|timeout|no_such_account|
# wrong_mailstore_or_disabled|account_in_maintenance|error.
sub get_folder {
    my ($email) = @_;
    my $esc = bash_escape($email);
    my $xml = '';
    for my $attempt (1, 2) {
        my $to = $attempt == 1 ? 60 : 120;
        $xml = `timeout $to zmsoap -z -m $esc GetFolderRequest \@tr=1 \@needGranteeName=1 2>&1`;
        my $exit = $? >> 8;
        return ($xml, 'ok') if $exit == 0;
        if ($exit == 124) {
            next if $attempt == 1;   # retry once at doubled timeout
            return (undef, 'timeout');
        }
        # non-timeout failure: classify from the error text and stop
        my $reason = 'error';
        $reason = 'no_such_account'              if $xml =~ /no such account/i;
        $reason = 'wrong_mailstore_or_disabled'  if $xml =~ /wrong host|account is not/i;
        $reason = 'account_in_maintenance'       if $xml =~ /maintenance/i;
        return (undef, $reason);
    }
    return (undef, 'error');
}

# Process one mailbox; returns hashref with inbound/stale row+script fragments.
sub process_user {
    my ($email) = @_;
    my ($xml, $status) = get_folder($email);
    return { status => $status } if $status ne 'ok';

    my (@in_csv, @in_sh, @st_csv, @st_sh);

    # (a) Inbound broken mount points
    while ($xml =~ /<(?:link|folder)[^>]*?broken="1"[^>]*?>/g) {
        my $tag = $&;
        my ($id)   = $tag =~ /\bid="([^"]+)"/;
        my ($path) = $tag =~ /absFolderPath="([^"]+)"/;
        my ($name) = $tag =~ /\bname="([^"]+)"/;
        next unless $id && $path;
        $path = xml_unescape($path);
        $name = xml_unescape($name);
        push @in_csv, '"'.csv($email).'","'.csv($id).'","'.csv($path).'","'.csv($name)."\"\n";
        push @in_sh, "# User: $email\n";
        push @in_sh, "# Remove broken mount point: $path\n";
        push @in_sh, "echo 'Removing broken mount point ID $id: $path'\n";
        push @in_sh, "zmmailbox -z -m ".bash_escape($email)." df ".bash_escape($id)." || echo '  [WARN] Failed'\n\n";
    }

    # (b) Stale ACL diff -- only for owners that actually have shares
    my $shares = $users_with_shares{$email};
    if ($shares) {
        # Build the live ACL set by walking the folder tree line by line.
        # A <grant> always appears immediately after its owning <folder>/<link>
        # open tag (inside that folder's <acl>), before the next folder tag, so
        # "most recent absFolderPath seen" correctly attributes each grant.
        my %actual;            # "path|zid" => 1 and "path|email" => 1
        my $cur_path;
        for my $line (split /\n/, $xml) {
            if ($line =~ /<(?:folder|link)[^>]*absFolderPath="([^"]+)"/) {
                $cur_path = xml_unescape($1);
            }
            if ($line =~ /<grant\b[^>]*>/ && defined $cur_path) {
                my ($gt)  = $line =~ /\bgt="([^"]+)"/;
                next unless defined $gt && $gt =~ /^(?:usr|grp)$/;
                my ($zid) = $line =~ /\bzid="([^"]+)"/;
                my ($d)   = $line =~ /\bd="([^"]+)"/;
                $d = xml_unescape($d) if defined $d;
                $actual{"$cur_path|".lc($zid)} = 1 if defined $zid;
                $actual{"$cur_path|".lc($d)}   = 1 if defined $d;
            }
        }

        for my $s (@$shares) {
            my $fp  = $s->{folder};
            my $zid = $s->{grantee_id};
            # Resolve the cached grantee to a primary email to match gfg/grant 'd'
            my $resolved = $id_to_mail{$zid}
                        || $id_to_mail{$s->{grantee_name}}
                        || ($s->{grantee_name} || $zid);
            $resolved = lc($resolved);

            my $hit = ($zid && $actual{"$fp|$zid"}) || $actual{"$fp|$resolved"};
            next if $hit;

            push @st_csv, '"'.csv($email).'","'.csv($fp).'","'.csv($resolved).'","'.csv($s->{rights})."\"\n";
            push @st_sh, "# Owner: $email\n";
            push @st_sh, "# Folder: $fp\n";
            push @st_sh, "# Stale grantee in cache (no matching ACL grant): $resolved\n";
            push @st_sh, "echo 'Removing stale ACL share: $email - $fp -> $resolved'\n";
            push @st_sh, "zmprov ma ".bash_escape($email)." -zimbraSharedItem ".bash_escape($s->{raw})." || echo '  [WARN] Failed'\n\n";
        }
    }

    return { status => 'ok', in_csv => \@in_csv, in_sh => \@in_sh,
             st_csv => \@st_csv, st_sh => \@st_sh };
}

# Fork a pool of workers; each takes a round-robin slice of the user list and
# writes its fragments to per-worker files, which the parent stitches together.
my @pids;
for my $w (0 .. $JOBS - 1) {
    my @mine = map { $users[$_] } grep { $_ % $JOBS == $w } 0 .. $#users;

    my $pid = fork();
    die "fork failed: $!" unless defined $pid;
    if ($pid == 0) {
        # ---- child ----
        open(my $fic, '>', "$tmpdir/in_csv.$w")  or die $!;
        open(my $fis, '>', "$tmpdir/in_sh.$w")   or die $!;
        open(my $fsc, '>', "$tmpdir/st_csv.$w")  or die $!;
        open(my $fss, '>', "$tmpdir/st_sh.$w")   or die $!;
        open(my $fsk, '>', "$tmpdir/skip.$w")    or die $!;
        for my $email (@mine) {
            my $r = process_user($email);
            if ($r->{status} ne 'ok') {
                print $fsk "$email\t$r->{status}\n";
                next;
            }
            print $fic @{$r->{in_csv}};
            print $fis @{$r->{in_sh}};
            print $fsc @{$r->{st_csv}};
            print $fss @{$r->{st_sh}};
        }
        close($_) for ($fic, $fis, $fsc, $fss, $fsk);
        exit 0;
    }
    push @pids, $pid;
}
waitpid($_, 0) for @pids;

# Stitch worker fragments together (in worker order, for stable output).
sub slurp_parts {
    my ($base) = @_;
    my @out;
    for my $w (0 .. $JOBS - 1) {
        my $f = "$tmpdir/$base.$w";
        next unless -e $f;
        open(my $fh, '<', $f) or next;
        local $/;
        push @out, <$fh>;
        close($fh);
    }
    return join('', @out);
}

my $inbound_csv_body = slurp_parts('in_csv');
my $inbound_sh_body  = slurp_parts('in_sh');
my $stale_csv_body   = slurp_parts('st_csv');
my $stale_sh_body    = slurp_parts('st_sh');
my $skipped_body     = slurp_parts('skip');

my $inbound_broken_count = ($inbound_csv_body =~ tr/\n//);
my $stale_acl_count      = ($stale_csv_body   =~ tr/\n//);
my $skipped_count        = ($skipped_body     =~ tr/\n//);

print "[PERL] Found $inbound_broken_count broken inbound mount points.\n";
print "[PERL] Found $stale_acl_count stale ACL entries.\n";
print "[PERL] Skipped $skipped_count unreachable/errored mailbox(es).\n" if $skipped_count;


# ----------------------------------------------------------------------
# STEP 4: Write Output
# ----------------------------------------------------------------------
if ($outbound_broken_count > 0) {
    open(my $c, '>', $outbound_csv); print $c "OwnerEmail,FolderPath,ShareType,GranteeId,Permissions\n", @outbound_csv_data; close($c);
    open(my $s, '>', $outbound_sh);  print $s "#!/bin/bash\n# Outbound Share Removal Script\n\nset -uo pipefail\n\n", @outbound_sh_data; close($s);
    chmod 0755, $outbound_sh;
} else {
    unlink $outbound_csv, $outbound_sh;
}

if ($stale_acl_count > 0) {
    open(my $c, '>', $stale_csv); print $c "OwnerEmail,FolderPath,ResolvedGrantee,Permissions\n", $stale_csv_body; close($c);
    open(my $s, '>', $stale_sh);  print $s "#!/bin/bash\n# Stale ACL Share Removal Script\n\nset -uo pipefail\n\n", $stale_sh_body; close($s);
    chmod 0755, $stale_sh;
} else {
    unlink $stale_csv, $stale_sh;
}

if ($inbound_broken_count > 0) {
    open(my $c, '>', $inbound_csv); print $c "UserEmail,FolderId,AbsolutePath,FolderName\n", $inbound_csv_body; close($c);
    open(my $s, '>', $inbound_sh);  print $s "#!/bin/bash\n# Inbound Mount Point Removal Script\n\nset -uo pipefail\n\n", $inbound_sh_body; close($s);
    chmod 0755, $inbound_sh;
} else {
    unlink $inbound_csv, $inbound_sh;
}

if ($skipped_count > 0) {
    open(my $sk, '>', $skipped_file);
    print $sk "# Accounts skipped (tab-separated: email <tab> reason)\n", $skipped_body;
    close($sk);
}

print "\n========================================\n";
print "SCAN COMPLETE\n";
print "========================================\n";

if ($outbound_broken_count == 0 && $stale_acl_count == 0 && $inbound_broken_count == 0) {
    print "Your Zimbra shared folders are completely healthy.\n";
    print "No broken shares were found. (No scripts generated).\n";
} else {
    print "[ACTION REQUIRED] Broken shares were found. Review the generated scripts below:\n\n";

    if ($outbound_broken_count > 0) {
        print "OUTBOUND SHARES (Invalid Recipients):\n";
        print "  CSV:    $outbound_csv\n";
        print "  Script: $outbound_sh\n\n";
    }
    if ($stale_acl_count > 0) {
        print "STALE ACL ENTRIES (Bug C):\n";
        print "  CSV:    $stale_csv\n";
        print "  Script: $stale_sh\n\n";
    }
    if ($inbound_broken_count > 0) {
        print "INBOUND SHARES (Mount Points):\n";
        print "  CSV:    $inbound_csv\n";
        print "  Script: $inbound_sh\n\n";
    }
}
print "Skipped accounts: $skipped_file\n" if $skipped_count > 0;
print "========================================\n";
PERL_EOF
Post Reply