Last active
January 23, 2026 18:42
-
-
Save maxgfr/d836a1349b7e807e9aeafc16e3ed2148 to your computer and use it in GitHub Desktop.
Mattermost read all notification script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| # Requirements: | |
| # - bash >= 4 (associative arrays + mapfile) | |
| # - curl | |
| # - jq | |
| if [[ -z "${BASH_VERSINFO:-}" || "${BASH_VERSINFO[0]}" -lt 4 ]]; then | |
| echo "❌ This script requires bash >= 4 (current: ${BASH_VERSION:-unknown})." >&2 | |
| echo " On macOS, install a newer bash (e.g. via Homebrew) and ensure it's first in PATH." >&2 | |
| exit 2 | |
| fi | |
| command -v curl >/dev/null 2>&1 || { echo "❌ Missing dependency: curl" >&2; exit 2; } | |
| command -v jq >/dev/null 2>&1 || { echo "❌ Missing dependency: jq" >&2; exit 2; } | |
| # Default values | |
| MM_USERNAME="" | |
| MM_PASSWORD="" | |
| MM_URL="" | |
| CACHE_MODE="refresh" # Options: use, refresh, clear | |
| # Parse command-line arguments | |
| while getopts "u:p:n:c:" opt; do | |
| case $opt in | |
| u) MM_USERNAME="$OPTARG" ;; | |
| p) MM_PASSWORD="$OPTARG" ;; | |
| n) MM_URL="$OPTARG" ;; | |
| c) CACHE_MODE="$OPTARG" ;; | |
| \?) echo "Invalid option: -$OPTARG" >&2; exit 1 ;; | |
| :) echo "Option -$OPTARG requires an argument." >&2; exit 1 ;; | |
| esac | |
| done | |
| # Check if all required arguments are provided | |
| if [[ -z "$MM_USERNAME" || -z "$MM_PASSWORD" || -z "$MM_URL" ]]; then | |
| echo "Usage: $0 -u <username> -p <password> -n <server_url> [-c <cache_mode>]" | |
| echo "" | |
| echo "Arguments:" | |
| echo " -u Username" | |
| echo " -p Password" | |
| echo " -n Mattermost server URL (e.g. https://mattermost.example.com)" | |
| echo " -c Cache mode (default: refresh)" | |
| echo " use - Use cached channels if available (default)" | |
| echo " refresh - Ignore cache and refetch all channels" | |
| echo " clear - Delete cache file and exit" | |
| exit 1 | |
| fi | |
| # Remove trailing slash from URL to avoid 301 redirects | |
| MM_URL="${MM_URL%/}" | |
| echo "=== Mattermost – Mark all channels and DMs as read ===" | |
| echo "Server: $MM_URL" | |
| echo "Username: $MM_USERNAME" | |
| echo "Cache mode: $CACHE_MODE" | |
| COOKIE_JAR=$(mktemp) | |
| HEADERS_LOG=$(mktemp) | |
| CHANNELS_FILE="" | |
| FAIL_LOG="" | |
| cleanup() { | |
| rm -f "$COOKIE_JAR" "$HEADERS_LOG" | |
| [[ -n "${CHANNELS_FILE}" ]] && rm -f "$CHANNELS_FILE" | |
| [[ -n "${FAIL_LOG}" ]] && rm -f "$FAIL_LOG" | |
| } | |
| trap cleanup EXIT | |
| echo | |
| echo "[INFO] Logging in..." | |
| LOGIN_HTTP_CODE=$(curl -s -w "%{http_code}" \ | |
| -D "$HEADERS_LOG" \ | |
| -c "$COOKIE_JAR" \ | |
| -o /dev/null \ | |
| -X POST "$MM_URL/api/v4/users/login" \ | |
| -H "Content-Type: application/json" \ | |
| -H "X-Requested-With: XMLHttpRequest" \ | |
| -d "{ | |
| \"login_id\": \"$MM_USERNAME\", | |
| \"password\": \"$MM_PASSWORD\" | |
| }") | |
| echo "[DEBUG] Login HTTP status: $LOGIN_HTTP_CODE" | |
| if [[ "$LOGIN_HTTP_CODE" != "200" ]]; then | |
| echo "❌ Login failed" | |
| echo "---- Response headers ----" | |
| cat "$HEADERS_LOG" | |
| rm -f "$COOKIE_JAR" "$HEADERS_LOG" | |
| exit 1 | |
| fi | |
| if ! grep -q "MMAUTHTOKEN" "$COOKIE_JAR"; then | |
| echo "❌ Login succeeded but no session cookie found" | |
| echo "---- Cookies ----" | |
| cat "$COOKIE_JAR" | |
| rm -f "$COOKIE_JAR" "$HEADERS_LOG" | |
| exit 1 | |
| fi | |
| echo "✅ Logged in (session cookie obtained)" | |
| # Get user ID | |
| echo "[INFO] Fetching user ID..." | |
| USER_ID=$(curl -s -b "$COOKIE_JAR" \ | |
| "$MM_URL/api/v4/users/me" | jq -r '.id') | |
| if [[ "$USER_ID" == "null" || -z "$USER_ID" ]]; then | |
| echo "❌ Failed to get user ID" | |
| rm -f "$COOKIE_JAR" "$HEADERS_LOG" | |
| exit 1 | |
| fi | |
| # Define cache file path (based on user ID and server URL) | |
| CACHE_FILE="/tmp/mattermost_channels_${USER_ID}_$(echo "$MM_URL" | sed 's/[^a-zA-Z0-9]/_/g').cache" | |
| # Handle cache mode | |
| if [[ "$CACHE_MODE" == "clear" ]]; then | |
| echo "[INFO] Cache mode: clear" | |
| if [[ -f "$CACHE_FILE" ]]; then | |
| echo "[INFO] Deleting cache file..." | |
| rm -f "$CACHE_FILE" | |
| echo "✅ Cache file deleted" | |
| else | |
| echo "[INFO] No cache file found" | |
| fi | |
| echo "🎉 Done!" | |
| exit 0 | |
| fi | |
| USE_CACHE=false | |
| # Check if cache file exists | |
| if [[ "$CACHE_MODE" == "use" && -f "$CACHE_FILE" ]]; then | |
| echo "[INFO] Found cached channels from previous run" | |
| echo "[INFO] Using cached channels to skip fetching..." | |
| USE_CACHE=true | |
| fi | |
| # Get all teams user is a member of | |
| echo "[INFO] Fetching all teams..." | |
| TEAMS_RESPONSE=$(curl -s -b "$COOKIE_JAR" \ | |
| "$MM_URL/api/v4/users/$USER_ID/teams") | |
| TEAM_COUNT=$(echo "$TEAMS_RESPONSE" | jq -r 'length') | |
| echo "[INFO] Found $TEAM_COUNT teams" | |
| # Display all teams | |
| for i in $(seq 0 $((TEAM_COUNT - 1))); do | |
| TEAM_NAME=$(echo "$TEAMS_RESPONSE" | jq -r ".[$i].name") | |
| TEAM_DISPLAY_NAME=$(echo "$TEAMS_RESPONSE" | jq -r ".[$i].display_name") | |
| echo "[INFO] - $TEAM_DISPLAY_NAME ($TEAM_NAME)" | |
| done | |
| # Build team ID to name lookup table | |
| declare -A TEAM_LOOKUP | |
| for i in $(seq 0 $((TEAM_COUNT - 1))); do | |
| TEAM_ID=$(echo "$TEAMS_RESPONSE" | jq -r ".[$i].id") | |
| TEAM_DISPLAY_NAME=$(echo "$TEAMS_RESPONSE" | jq -r ".[$i].display_name") | |
| TEAM_LOOKUP["$TEAM_ID"]="$TEAM_DISPLAY_NAME" | |
| done | |
| # Get all channels with pagination | |
| if [[ "$USE_CACHE" == true ]]; then | |
| echo "[INFO] Loading channels from cache..." | |
| ALL_CHANNELS=() | |
| while IFS= read -r CHANNEL_ID; do | |
| [[ -n "$CHANNEL_ID" ]] && ALL_CHANNELS+=("$CHANNEL_ID") | |
| done < "$CACHE_FILE" | |
| # De-duplicate (cache file may contain duplicates if it was appended) | |
| mapfile -t ALL_CHANNELS < <(printf "%s\n" "${ALL_CHANNELS[@]}" | sort -u) | |
| echo "[INFO] Loaded ${#ALL_CHANNELS[@]} channels from cache" | |
| else | |
| echo "[INFO] Fetching all channels..." | |
| PAGE=0 | |
| PER_PAGE=200 | |
| ALL_CHANNELS=() | |
| DM_CHANNELS=() | |
| declare -A CHANNELS_BY_TEAM | |
| while true; do | |
| echo "[INFO] Fetching page $PAGE..." | |
| CHANNELS_RESPONSE=$(curl -s -b "$COOKIE_JAR" \ | |
| "$MM_URL/api/v4/users/$USER_ID/channels?page=$PAGE&per_page=$PER_PAGE") | |
| # Get channel data from this page | |
| PAGE_COUNT=$(echo "$CHANNELS_RESPONSE" | jq -r 'length') | |
| if [[ "$PAGE_COUNT" -eq 0 ]]; then | |
| echo "[INFO] No more channels on page $PAGE" | |
| break | |
| fi | |
| echo "[INFO] Found $PAGE_COUNT channels on page $PAGE" | |
| # Process each channel | |
| for i in $(seq 0 $((PAGE_COUNT - 1))); do | |
| CHANNEL_ID=$(echo "$CHANNELS_RESPONSE" | jq -r ".[$i].id") | |
| TEAM_ID=$(echo "$CHANNELS_RESPONSE" | jq -r ".[$i].team_id") | |
| CHANNEL_TYPE=$(echo "$CHANNELS_RESPONSE" | jq -r ".[$i].type") | |
| ALL_CHANNELS+=("$CHANNEL_ID") | |
| # Track DM channels separately | |
| if [[ "$CHANNEL_TYPE" == "D" || "$CHANNEL_TYPE" == "G" ]]; then | |
| DM_CHANNELS+=("$CHANNEL_ID") | |
| fi | |
| # Get team name from lookup table | |
| if [[ "$TEAM_ID" != "null" && -n "$TEAM_ID" && -n "${TEAM_LOOKUP[$TEAM_ID]-}" ]]; then | |
| TEAM_NAME="${TEAM_LOOKUP[$TEAM_ID]}" | |
| else | |
| TEAM_NAME="Direct Messages" | |
| fi | |
| # Group by team | |
| if [[ -z "${CHANNELS_BY_TEAM[$TEAM_NAME]-}" ]]; then | |
| CHANNELS_BY_TEAM[$TEAM_NAME]=0 | |
| fi | |
| CHANNELS_BY_TEAM[$TEAM_NAME]=$(( ${CHANNELS_BY_TEAM[$TEAM_NAME]-0} + 1 )) | |
| done | |
| # Check if we've fetched all channels | |
| if [[ "$PAGE_COUNT" -lt "$PER_PAGE" ]]; then | |
| echo "[INFO] Last page reached" | |
| break | |
| fi | |
| PAGE=$((PAGE + 1)) | |
| # Safety limit: max 100 pages | |
| if [[ "$PAGE" -gt 100 ]]; then | |
| echo "[WARNING] Reached maximum page limit (100)" | |
| break | |
| fi | |
| done | |
| # Save channels to cache (overwrite to avoid stale / duplicated IDs) | |
| echo "[INFO] Saving channels to cache for future runs..." | |
| printf "%s\n" "${ALL_CHANNELS[@]}" | sort -u > "$CACHE_FILE" | |
| echo "[INFO] Total channels found: ${#ALL_CHANNELS[@]}" | |
| echo "[INFO] Channels by team:" | |
| for TEAM_NAME in "${!CHANNELS_BY_TEAM[@]}"; do | |
| echo "[INFO] - $TEAM_NAME: ${CHANNELS_BY_TEAM[$TEAM_NAME]} channels" | |
| done | |
| echo "[INFO] Direct messages found: ${#DM_CHANNELS[@]}" | |
| fi | |
| if [[ ${#ALL_CHANNELS[@]} -eq 0 ]]; then | |
| echo "❌ No channels found for this user" | |
| rm -f "$CACHE_FILE" | |
| exit 1 | |
| fi | |
| # Mark all channels as read in parallel | |
| echo "[INFO] Marking all channels as read (parallel processing, 20 at a time)..." | |
| # Create a temporary file to store channel IDs | |
| CHANNELS_FILE=$(mktemp) | |
| for CHANNEL_ID in "${ALL_CHANNELS[@]}"; do | |
| echo "$CHANNEL_ID" >> "$CHANNELS_FILE" | |
| done | |
| FAIL_LOG=$(mktemp) | |
| # Mattermost has (at least) two relevant endpoints: | |
| # - POST /api/v4/channels/members/me/view -> clears unread + mention counts (badge/pastille) | |
| # - POST /api/v4/users/me/channels/{channel_id}/posts/read -> marks posts as read (older clients) | |
| mark_channel_read() { | |
| local channel_id="$1" | |
| # 1) Prefer "view" because it clears mention counts too. | |
| local view_code | |
| view_code=$(curl -s -o /dev/null -w "%{http_code}" \ | |
| -X POST -b "$COOKIE_JAR" \ | |
| "$MM_URL/api/v4/channels/members/me/view" \ | |
| -H "Content-Type: application/json" \ | |
| -d "{\"channel_id\":\"$channel_id\",\"prev_channel_id\":\"$channel_id\"}" \ | |
| ) || view_code="000" | |
| if [[ "$view_code" != "200" ]]; then | |
| # Some servers ignore/complain about prev_channel_id; retry without it. | |
| view_code=$(curl -s -o /dev/null -w "%{http_code}" \ | |
| -X POST -b "$COOKIE_JAR" \ | |
| "$MM_URL/api/v4/channels/members/me/view" \ | |
| -H "Content-Type: application/json" \ | |
| -d "{\"channel_id\":\"$channel_id\"}" \ | |
| ) || view_code="000" | |
| fi | |
| # 2) Extra: mark posts read (best-effort; some servers use only one of the two). | |
| local posts_code | |
| posts_code=$(curl -s -o /dev/null -w "%{http_code}" \ | |
| -X POST -b "$COOKIE_JAR" \ | |
| "$MM_URL/api/v4/users/me/channels/$channel_id/posts/read" \ | |
| ) || posts_code="000" | |
| if [[ "$view_code" == "200" || "$posts_code" == "200" ]]; then | |
| return 0 | |
| fi | |
| echo "channel_id=$channel_id view=$view_code posts_read=$posts_code" >> "$FAIL_LOG" | |
| return 0 | |
| } | |
| export MM_URL COOKIE_JAR FAIL_LOG | |
| export -f mark_channel_read | |
| # Process channels in parallel using xargs (bash needed for exported function) | |
| cat "$CHANNELS_FILE" | xargs -P 20 -n 1 bash -c 'mark_channel_read "$1"' _ | |
| FAIL_COUNT=$(wc -l < "$FAIL_LOG" | tr -d ' ') | |
| TOTAL_COUNT=${#ALL_CHANNELS[@]} | |
| SUCCESS_COUNT=$((TOTAL_COUNT - FAIL_COUNT)) | |
| echo "[INFO] Processed $SUCCESS_COUNT/$TOTAL_COUNT channels" | |
| if [[ "$FAIL_COUNT" -gt 0 ]]; then | |
| echo "[WARNING] $FAIL_COUNT channels could not be marked as read/viewed. First failures:" | |
| head -n 10 "$FAIL_LOG" | sed 's/^/[WARNING] /' | |
| fi | |
| rm -f "$CHANNELS_FILE" | |
| echo "✅ All ${#ALL_CHANNELS[@]} channels are now marked as read" | |
| # Best-effort: also mark all threads as read (if the server supports it) | |
| for i in $(seq 0 $((TEAM_COUNT - 1))); do | |
| TEAM_ID=$(echo "$TEAMS_RESPONSE" | jq -r ".[$i].id") | |
| THREADS_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ | |
| -X PUT -b "$COOKIE_JAR" \ | |
| "$MM_URL/api/v4/users/me/teams/$TEAM_ID/threads/read" \ | |
| ) || THREADS_CODE="000" | |
| if [[ "$THREADS_CODE" == "200" ]]; then | |
| echo "[INFO] Marked threads as read for team_id=$TEAM_ID" | |
| fi | |
| done | |
| # Note: Mentions endpoint not available on this server | |
| echo "[INFO] Note: Mentions endpoint not available on this Mattermost server" | |
| echo "[INFO] Skipping mentions processing" | |
| echo "🎉 All channels and DMs are now marked as read!" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment