Zimbra Accounts Not Logged In: 4 Script Mistakes to Avoid
To find Zimbra accounts not logged in for the last 30 days, you search LDAP for accounts whose zimbraLastLogonTimestamp is older than a date. The first version of this post shared a script, zimbra_lastlogin.sh, that does this with ldapsearch and a long chain of sed commands, writing the result to /tmp/lastlogin_time. I ran that script again, exactly as it was published, against a test directory. It does not run at all, and once the quoting is fixed it still drops accounts and mangles names. This rewrite shows each problem with real output and gives a script that produces a clean CSV.
Lab note: Zimbra is not installed in this lab. The test runs OpenLDAP slapd in a debian:13 container with the Zimbra attributes the script filters on (zimbraLastLogonTimestamp as a generalized time, zimbraAccountStatus, zimbraIsSystemResource, the zimbraCalendarResource class). /opt/zimbra/bin/zmlocalconfig is a stub that prints key = value like the example in the Zimbra Administrator's Guide, and "today" is fixed at 6 November 2026 with faketime.
Contents
- Test accounts: logged in recently and not logged in
- The old script for accounts not logged in
- 4 problems in the old not logged in script
- 1. It does not run: a quote closes too early
- 2. The output is broken by colons and base64 names
- 3. Users who have not logged in even once are missing
- 4. No date in the report, and the password in the process list
- A fixed script: CSV of accounts not logged in
- Before you act on the not logged in list
- Conclusion
Test accounts: logged in recently and not logged in
Eight accounts cover the cases a real mail server has: one recent login, three old ones, a user with a Vietnamese name, a display name with a colon, a user without a display name, one that never logged in, a closed account and a meeting room:
mail: an.nguyen@example.com
zimbraAccountStatus: active
displayName:: Tmd1eeG7hW4gVsSDbiBBbg==
zimbraLastLogonTimestamp: 20260402080000Z
mail: helpdesk@example.com
zimbraAccountStatus: active
displayName: IT: Helpdesk
zimbraLastLogonTimestamp: 20260115090000Z
mail: neverlogged@example.com
zimbraAccountStatus: active
displayName: Never Logged
mail: room1@example.com
zimbraAccountStatus: active
displayName: Meeting Room 1
zimbraLastLogonTimestamp: 20260101000000Z
zimbraIsSystemResource: TRUE
Note the double colon on the first one. LDIF prints any value with non-ASCII characters, such as "Nguyễn Văn An", in base64 after displayName::. Any script that reads ldapsearch output line by line has to handle that.
The old script for accounts not logged in
Here is the script from the original post, with its line breaks restored:
#!/bin/bash
# HuuPV author
rm -f /tmp/lastlogin_time
pastmonth=$(date -d "-1 month" +%Y%m%d)
LDAP_HOST=$(/opt/zimbra/bin/zmlocalconfig ldap_host | cut -d ' ' -f3)
LDAP_PASSWORD=$(/opt/zimbra/bin/zmlocalconfig -s zimbra_ldap_password | cut -d ' ' -f3)
LDAP_USERDN=$(/opt/zimbra/bin/zmlocalconfig zimbra_ldap_userdn | cut -d ' ' -f3)
LDAPSEARCH="/opt/zimbra/bin/ldapsearch -D "$LDAP_USERDN" -w $LDAP_PASSWORD -H ldap://$LDAP_HOST -LLL -o ldif-wrap=no"
ZIMBRA_ACCOUNT_STATUS="zimbraAccountStatus"
$LDAPSEARCH "(&(zimbraLastLogonTimestamp<=$pastmonth"000000Z)(!(zimbraIsSystemResource=TRUE))($ZIMBRA_ACCOUNT_STATUS=active)(mail=*)(&(objectclass=zimbraAccount)(!(objectclass=zimbraCalendarResource))))" \
$ZIMBRA_ACCOUNT_STATUS displayName \
| sed 's/ou=people,//g' \
| sed 's/dn: uid=/:/g' \
| sed 's/,dc=/@/' \
| sed 's/,dc=/./g' \
| sed 's/,dc=/./g' \
| sed ':a;N;$!ba;s/\n/ /g' \
| sed "s/$ZIMBRA_ACCOUNT_STATUS: //g" \
| sed "s/displayName: //g" \
| sed 's/:/\n/g' \
| sed '/^\s*$/d' \
| sort >> /tmp/lastlogin_time
The idea for finding users not logged in is sound: date -d "-1 month" +%Y%m%d gives the threshold (20261006 in the lab), and the LDAP filter keeps active, non-resource accounts whose last logon is at or before that date.
4 problems in the old not logged in script
1. It does not run: a quote closes too early
As published, bash stops before any search is made:
/lab/zimbra_lastlogin.sh: line 16: syntax error near unexpected token `)'
The filter string ends at the quote after $pastmonth, so 000000Z) and the rest of the filter are parsed as shell code. Put the suffix inside the quotes with braces: <=${pastmonth}000000Z). That is the only change in the next test.
2. The output is broken by colons and base64 names
With the quote fixed, the script runs and writes this file (cat -A marks each line end with $):
$ ./zimbra_lastlogin_q.sh; cat -A /tmp/lastlogin_time
Helpdesk $
Tmd1eeG7hW4gVsSDbiBBbg== $
an.nguyen@example.com active displayName$
helpdesk@example.com active IT$
nodisplay@example.com active $
olduser@example.com active Old User $
The pipeline joins everything into one line and splits it again on every colon. "IT: Helpdesk" is cut in two, and the second half becomes its own line at the top after sort. The base64 name keeps one colon of displayName::, so the user gets the word "displayName" as a name and the encoded value lands on a separate line. On a server with many Vietnamese, accented or "Dept: Name" display names, the file quickly becomes unreadable.
3. Users who have not logged in even once are missing
neverlogged@example.com is active and has never logged in, yet it is not in the file. An account without zimbraLastLogonTimestamp never matches a <= filter. These are often the most interesting accounts to review. The details of this LDAP behaviour, and the time zone trap in the date, are tested in check accounts that are not active in Zimbra.
4. No date in the report, and the password in the process list
The script asks only for zimbraAccountStatus and displayName, so the report says who has not logged in but not since when. It also passes the LDAP password with -w. While ldapsearch runs, any local user can read it from the process list:
ldapsearch -x -D cn=admin,dc=example,dc=com -w adminpw -H ldap://localhost -LLL (uid=*) mail
On a real server that is the password of zimbra_ldap_userdn, which has wide rights in the directory. ldapsearch -y file reads it from a file instead.
A fixed script: CSV of accounts not logged in
This version reads the LDAP settings with zmlocalconfig -m nokey (the guide lists the formats plain, xml, shell and nokey), passes the password in a temporary file, includes accounts that never logged in, decodes base64 names, and prints a CSV of users not logged in, sorted by last logon:
#!/bin/bash
# usage: inactive_accounts.sh [days] (default 30) -> CSV: mail,status,last_logon_utc,displayName
# Run as the zimbra user. Lists active accounts whose last logon is older than N days OR that never logged in.
set -euo pipefail
days=${1:-30}
T=$(date -u -d "$days days ago" +%Y%m%d%H%M%SZ)
LDAP_URL=$(zmlocalconfig -m nokey ldap_master_url)
LDAP_USERDN=$(zmlocalconfig -m nokey zimbra_ldap_userdn)
PWFILE=$(mktemp); trap 'rm -f "$PWFILE"' EXIT
zmlocalconfig -s -m nokey zimbra_ldap_password | tr -d '\n' > "$PWFILE"
FILTER="(&(objectClass=zimbraAccount)(!(objectClass=zimbraCalendarResource))(!(zimbraIsSystemResource=TRUE))(zimbraAccountStatus=active)(mail=*)(|(zimbraLastLogonTimestamp<=$T)(!(zimbraLastLogonTimestamp=*))))"
echo "mail,status,last_logon_utc,displayName"
ldapsearch -x -LLL -o ldif-wrap=no -H "$LDAP_URL" -D "$LDAP_USERDN" -y "$PWFILE" "$FILTER" \
mail zimbraAccountStatus zimbraLastLogonTimestamp displayName |
while IFS= read -r line || [ -n "$line" ]; do
case $line in
dn:*) mail= st= ll=never dn= ;;
mail:\ *) mail=${line#mail: } ;;
zimbraAccountStatus:\ *) st=${line#*: } ;;
zimbraLastLogonTimestamp:\ *) v=${line#*: }; ll="${v:0:4}-${v:4:2}-${v:6:2} ${v:8:2}:${v:10:2}" ;;
displayName::\ *) dn=$(printf '%s' "${line#displayName:: }" | base64 -d) ;;
displayName:\ *) dn=${line#displayName: } ;;
"") [ -n "${mail:-}" ] && printf '%s,%s,%s,"%s"\n' "$mail" "$st" "$ll" "${dn//\"/\"\"}"; mail= ;;
esac
done | sort -t, -k3,3
On the same directory, users not logged in for 30 and for 230 days:
$ ./inactive_accounts.sh 30
mail,status,last_logon_utc,displayName
helpdesk@example.com,active,2026-01-15 09:00,"IT: Helpdesk"
nodisplay@example.com,active,2026-02-01 10:00,""
olduser@example.com,active,2026-03-10 12:15,"Old User"
an.nguyen@example.com,active,2026-04-02 08:00,"Nguyễn Văn An"
neverlogged@example.com,active,never,"Never Logged"
$ ./inactive_accounts.sh 230
mail,status,last_logon_utc,displayName
helpdesk@example.com,active,2026-01-15 09:00,"IT: Helpdesk"
nodisplay@example.com,active,2026-02-01 10:00,""
olduser@example.com,active,2026-03-10 12:15,"Old User"
neverlogged@example.com,active,never,"Never Logged"

Every account not logged in since the threshold is there, every name is intact, the never-logged-in account is listed with never, and the closed account and the meeting room are still excluded. The threshold is now built in UTC (date -u), because Zimbra stores the timestamp in UTC.
Before you act on the not logged in list
The Zimbra attribute file describes zimbraLastLogonTimestamp as a rough estimate that is only updated every few days (zimbraLastLogonTimestampFrequency), so use it for 30-day or 90-day reviews, not for audits to the hour. Users who only receive mail or use a mobile app may also show up as not logged in. Review the CSV, then lock rather than delete; see lock and unlock a Zimbra account from the command line.
To run the report every month, the old post's cron idea still works. Install it in the zimbra user's crontab and write the output to a dated file, for example 0 6 1 * * /opt/zimbra/inactive_accounts.sh 30 > /tmp/inactive-$(date +\%F).csv; in crontab, % must be escaped as \%.
Conclusion
Listing Zimbra accounts not logged in for X days is one LDAP filter, but the published zimbra_lastlogin.sh could not run because of a misplaced quote, and with that fixed its sed chain split names on colons, printed base64 for non-ASCII names, skipped users who never logged in and exposed the LDAP password. The fixed script keeps the same filter idea, adds the never-logged-in case, reads LDIF properly and writes a CSV you can sort and hand to a manager.
Comments
Post a Comment