Barnes Tech

Back

If your users live in Gmail, clicking a mailto: link and having Apple Mail pop open is a small papercut that never stops bleeding. On a single Mac you fix it once in System Settings and forget about it. Across a managed fleet, “just have everyone open Settings and change a dropdown” is not a plan. It’s a support ticket generator.

So I wrote a script to do it for me, and I wanted it to be the kind of thing I could hand to an MDM and trust. That turned out to be more interesting than I expected, because making Chrome the default mail handler on macOS has a few sharp edges that only show up when you run it the way an MDM runs things: as root, with no logged-in shell, and no guarantee the environment looks anything like a normal user session.

Here’s the script, the parts that matter, and how I deploy it.

Why this is harder than it looks#

A few things conspire against you here:

  1. The mailto default is per-user. It lives in each user’s Launch Services database. If you run a fix as root (which is how MDM-deployed scripts run), you’ll happily set the default for root, a user who will never click a mailto: link in their life, and the actual human at the keyboard sees no change.
  2. $HOME lies. When a script is launched from a root/MDM context, $HOME can be unset or point somewhere useless. Anything that reaches for ~/Library/... without resolving the real home directory is building on sand.
  3. Chrome has to want the job. macOS won’t let you assign mailto to an app that hasn’t declared it can handle the scheme. Chrome only declares mailto after you enable a web mail handler (like letting Gmail register itself). Until then, every “set the default” call fails quietly and you’re left scratching your head.

The script handles all three, and it tries more than one way to get the job done so a single failure doesn’t sink the whole thing.

The strategy#

The script walks a ladder and stops the moment the handler is confirmed:

  1. Locate Chrome and resolve its real bundle ID.
  2. (Re)register Chrome with Launch Services so it can declare mailto.
  3. Primary: set the default via the sanctioned NSWorkspace API (a tiny bit of inline Swift).
  4. Fallback: rewrite the user’s Launch Services secure.plist directly, then flush cfprefsd so the change gets re-read.
  5. Verify by asking Launch Services which app actually owns mailto: now.

Swift is the preferred path because it uses Apple’s real API. But if the Command Line Tools aren’t installed, the script automatically drops to a PlistBuddy-only path so it still works on a bare machine. Self-contained was the whole goal.

Running in the right user context#

This is the part that makes it MDM-safe. If the script is invoked as root, it figures out who’s actually logged in at the console and re-runs itself as that person:

if [[ "$(id -u)" -eq 0 ]]; then
  consoleUser=$(stat -f%Su /dev/console)
  if [[ -z "$consoleUser" || "$consoleUser" == "root" || "$consoleUser" == "loginwindow" ]]; then
    die "No regular console user is logged in — nothing to set."
  fi
  uid=$(id -u "$consoleUser")
  log "Running as root; retargeting console user: $consoleUser"
bash

There’s a gotcha here that cost me some time. MDMs stage the script in a root-only temp directory (mode 700), so the obvious sudo -u <user> "$0" fails with Permission denied. The target user literally can’t read the file. The fix is to stage a world-readable copy in /private/tmp, run that as the console user, then clean it up:

selfCopy="/private/tmp/set-default-mail-chrome.$$.sh"
cp "$0" "$selfCopy" || die "Could not stage a user-readable copy at $selfCopy"
chmod 755 "$selfCopy"
launchctl asuser "$uid" sudo -H -u "$consoleUser" "$selfCopy" "$@"
rc=$?
rm -f "$selfCopy"
exit $rc
bash

launchctl asuser is what gets you into the user’s GUI session. Without it, Launch Services changes don’t take in the way you’d expect.

Don’t trust $HOME#

Once we’re running as the user, we resolve the home directory from the directory service instead of believing whatever $HOME claims to be:

TARGET_USER="$(id -un)"
USER_HOME="$(/usr/bin/dscl . -read "/Users/$TARGET_USER" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
[[ -n "$USER_HOME" ]] || USER_HOME="${HOME:-/Users/$TARGET_USER}"
export HOME="$USER_HOME"
SECURE_PLIST="$USER_HOME/Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist"
bash

dscl is the source of truth. $HOME is the fallback-of-last-resort, not the starting point.

Primary path: the NSWorkspace API#

The cleanest way to set a default handler is the API Apple actually wants you to use. The script shells out to a few lines of inline Swift:

NSWorkspace.shared.setDefaultApplication(at: appURL, toOpenURLsWithScheme: scheme) { error in
    if let error = error {
        FileHandle.standardError.write("LS error: \(error.localizedDescription)\n".data(using: .utf8)!)
        failed = true
    }
    group.leave()
}
swift

When this works, it’s the right answer and we’re done. The script sets it, waits a beat, and checks whether Launch Services agrees. If the API reports success but the handler isn’t confirmed yet, it flushes cfprefsd and checks again before giving up on this path.

Fallback path: rewrite the plist directly#

If the API path doesn’t stick (or Swift isn’t installed), the script edits the LSHandlers array in the user’s secure.plist by hand, removing any existing mailto entry and adding one pointing at Chrome’s (lowercased) bundle ID:

flush_ls() {
  # Make Launch Services / cfprefsd re-read the on-disk plist.
  /usr/bin/killall cfprefsd >/dev/null 2>&1 || true
  [[ -x "$LSREGISTER" ]] && "$LSREGISTER" -f "$CHROME_APP" >/dev/null 2>&1 || true
  sleep 1
}
bash

Bundle IDs are stored lowercased in that array, which is the kind of detail that’ll have you staring at a “correct” plist wondering why nothing changed. Editing the file is only half of it. You have to kick cfprefsd so the running system re-reads what you just wrote.

There are two flavors of this fallback: a Swift version that uses PropertyListSerialization, and a pure-PlistBuddy version for machines without a Swift toolchain. Same outcome, different tools.

What about utiluti?#

If you’ve spent any time in the macadmin world, you’re probably thinking “why not just use utiluti?”, Armin Briegel’s excellent command-line tool for reading and setting default apps for URL schemes and file types. It’s well-maintained, it’s broader than what I’m doing here (it handles UTIs and file-type defaults too, not just URL schemes), and for setting mailto it’s genuinely a one-liner:

utiluti url set mailto com.google.Chrome
bash

So why the homegrown script? A few honest differences, none of which make utiluti “wrong”, they’re just trade-offs:

  • utiluti is a binary you have to ship. That means building, codesigning, and notarizing it, then packaging it into your MDM. My script is a single self-contained .sh file you paste into a custom-script payload and you’re done. For one narrow job, that’s less to manage.
  • utiluti expects to run as the current user. The README says so explicitly, so you still have to wrap it in the same launchctl asuser / drop-to-console-user dance, plus handle the staging gotchas, yourself. My script bakes that context-handling in, which was honestly the hardest part to get right.
  • No Command Line Tools? No problem. My script falls back to a PlistBuddy-only path on a bare machine. utiluti needs its (Swift-built) binary present.
  • The fallback. utiluti sticks to the sanctioned API, which is the correct and cleaner choice. My script tries that first too, but will fall back to rewriting the plist directly when the API doesn’t take. That’s more of a sledgehammer, and you should reach for it knowing that.

And here’s the important part: utiluti hits the exact same Chrome wall. It can’t make Chrome the mailto default any more than my script can until Chrome has registered itself as a handler, so you need the Chrome Enterprise policy below either way.

So: if you’re already standardizing default-app management across a fleet, utiluti is the more general, better-supported tool and I’d happily reach for it. For a single, self-contained “make Chrome the mail handler and tell me clearly if it didn’t work” script I can push without packaging anything, this is what I ended up with. Different tools for slightly different jobs.

Deploying it with an MDM#

This is the payoff. Because the script handles its own root-to-user retargeting, you don’t have to do anything clever on the MDM side. You just drop it in as a custom script and let it run as root. The script does the rest:

  • It detects it’s running as root and re-targets the console user automatically.
  • It bails cleanly (with a clear message) if nobody’s logged in, so it’s safe to run on a schedule.
  • It stages its own readable copy to dodge the mode-700 temp directory problem.
  • It verifies the result and exits non-zero if it couldn’t confirm the change, so your MDM reports a real success or a real failure instead of a shrug.

I’ve only run this through Iru (formerly Kandji) as a custom script, where it slots in neatly as a one-off or a recurring task. But there’s nothing Iru-specific in it. Any MDM that can push a shell script and run it as root (Jamf, Mosyle, Addigy, Workspace ONE, etc.) should handle it the same way, because the hard parts are solved inside the script rather than in the platform’s config.

A couple of deployment notes:

  • Run it as root. That’s the default for MDM scripts anyway, and it’s required for the launchctl asuser retargeting to work.
  • Run it after login, or on a schedule. If no human is at the console, there’s no per-user default to set and the script exits early on purpose.
  • Watch the exit code. Non-zero means it couldn’t confirm Chrome as the handler, and nine times out of ten that’s the Chrome problem below, not a bug in the script.

Don’t forget the Chrome side#

Here’s the piece that bit me, and the reason a perfectly good script can still “fail.” macOS will not let you set mailto to an app that hasn’t registered itself as a handler for that scheme. Out of the box, Chrome doesn’t declare mailto until a web mail handler is enabled: for example, until you let mail.google.com register itself from chrome://settings/handlers. Do it by hand and you’ll see the little handler icon in the address bar; click it, allow Gmail, and now Chrome can be the default.

On a single machine that’s a 10-second click. Across a fleet, you don’t want to ask every user to do it, so you push it with Chrome Enterprise policy. The relevant one is RegisteredProtocolHandlers, which lets you pre-register a handler (like Gmail for mailto) so Chrome shows up as a willing mailto owner before the user ever touches a setting. A managed config looks roughly like this:

{
  "RegisteredProtocolHandlers": [
    {
      "protocol": "mailto",
      "url": "https://mail.google.com/mail/?extsrc=mailto&url=%s",
      "default": true
    }
  ]
}
json

Push that policy and then run the script, and the two halves meet in the middle: Chrome is allowed to be a mailto handler, and macOS is told to make it the default one. Skip the policy and the script will dutifully try, fail to confirm, and tell you exactly why. The error message points you straight at chrome://settings/handlers.

The order that works reliably across a fleet:

  1. Push the RegisteredProtocolHandlers policy via Chrome Enterprise / your MDM’s managed-app config.
  2. Deploy and run the script as root.
  3. Verify with open mailto:test@example.com. Chrome should answer.

What I learned#

A few things I’ll carry into the next “set a per-user default on a managed Mac” job:

  1. Assume root, act as the user. Anything per-user that an MDM runs has to retarget the console user itself, or it’s quietly setting preferences nobody will ever see.
  2. Resolve the real home directory. $HOME is a suggestion, not a fact, in these contexts.
  3. Have a fallback. The sanctioned API is the right first move, but a direct-plist-plus-cfprefsd-flush path saves the day when it doesn’t take.
  4. The OS half and the app half have to agree. macOS Launch Services and Chrome’s protocol-handler registration are two separate switches, and you don’t get the behavior you want until both are flipped.
  5. Make failures legible. When the script can’t confirm the change, it says why and what to do about it, which means future-me reads one message instead of re-debugging the whole thing.

The whole script#

Here it is, end to end. It’s safe to run as the logged-in user directly, and it’ll retarget itself if launched as root.

#!/bin/bash
#
# set-default-mail-chrome.sh
#
# Sets the default macOS mail handler (the mailto: URL scheme) to Google Chrome
# for the currently logged-in user, as resiliently and self-containedly as
# possible. Safe to run from an MDM as a custom script (it handles the root/MDM
# execution context).
#
# Strategy (each step verifies; stops as soon as the handler is confirmed):
#   1. Locate Chrome and resolve its real bundle ID.
#   2. (Re)register Chrome with Launch Services so it can declare mailto.
#   3. Primary: set via the sanctioned NSWorkspace API (Swift).
#   4. Fallback: rewrite the user's LaunchServices secure.plist LSHandlers entry
#      directly, then flush cfprefsd so the change is re-read.
#   5. Verify by asking Launch Services which app now owns mailto:.
#
# Swift (Xcode or Command Line Tools) is preferred. If Swift is absent the
# script automatically uses a PlistBuddy-only path so it still works.
#
# Usage:  ./set-default-mail-chrome.sh
# Run as the target user. If run as root it auto-retargets the console user.

set -uo pipefail   # not -e: we handle failures explicitly so fallbacks can run

SCHEME="mailto"
DEFAULT_BUNDLE_ID="com.google.Chrome"
LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister"
# SECURE_PLIST is resolved below, after the target user is known — do NOT use
# $HOME here (it can be unset when launched from a root/MDM context).

log()  { printf '  %s\n' "$*"; }
ok()   { printf '✓ %s\n' "$*"; }
warn() { printf '! %s\n' "$*" >&2; }
die()  { printf '✗ %s\n' "$*" >&2; exit 1; }

# --- Run in the right user context -----------------------------------------
# The mailto default is per-user, so a root invocation must drop to the console
# user or it would (uselessly) edit root's Launch Services database.
if [[ "$(id -u)" -eq 0 ]]; then
  consoleUser=$(stat -f%Su /dev/console)
  if [[ -z "$consoleUser" || "$consoleUser" == "root" || "$consoleUser" == "loginwindow" ]]; then
    die "No regular console user is logged in — nothing to set."
  fi
  uid=$(id -u "$consoleUser")
  log "Running as root; retargeting console user: $consoleUser"
  # The script may be staged in a root-only temp dir (mode 700), so
  # `sudo -u <user> "$0"` fails with "Permission denied". Stage a world-readable
  # copy in /private/tmp, run THAT as the console user, then clean it up.
  # -H so sudo sets HOME to the target user's home; the resolver below is the
  # real safety net in case it doesn't.
  selfCopy="/private/tmp/set-default-mail-chrome.$$.sh"
  cp "$0" "$selfCopy" || die "Could not stage a user-readable copy at $selfCopy"
  chmod 755 "$selfCopy"
  launchctl asuser "$uid" sudo -H -u "$consoleUser" "$selfCopy" "$@"
  rc=$?
  rm -f "$selfCopy"
  exit $rc
fi

# Resolve the acting user's real home directory. Don't trust $HOME — it may be
# unset or wrong when this is launched from a root/MDM context, and `set -u`
# would otherwise abort on the first reference.
TARGET_USER="$(id -un)"
USER_HOME="$(/usr/bin/dscl . -read "/Users/$TARGET_USER" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
[[ -n "$USER_HOME" ]] || USER_HOME="${HOME:-/Users/$TARGET_USER}"
export HOME="$USER_HOME"
SECURE_PLIST="$USER_HOME/Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist"

echo "Default-mail → Chrome  (user: $TARGET_USER, home: $USER_HOME)"

# --- Toolchain detection ----------------------------------------------------
SWIFT_BIN="$(command -v swift 2>/dev/null || true)"
if [[ -n "$SWIFT_BIN" ]]; then
  log "Swift toolchain: $SWIFT_BIN"
else
  warn "Swift not found — using PlistBuddy fallback path (install with 'xcode-select --install' for the preferred API path)."
fi

# --- Step 1: locate Chrome, resolve bundle ID -------------------------------
CHROME_APP=""
for p in "/Applications/Google Chrome.app" \
         "$HOME/Applications/Google Chrome.app" \
         "/Applications/Google Chrome Beta.app" \
         "/Applications/Google Chrome Dev.app"; do
  [[ -d "$p" ]] && { CHROME_APP="$p"; break; }
done
if [[ -z "$CHROME_APP" ]]; then
  CHROME_APP="$(mdfind "kMDItemCFBundleIdentifier == '$DEFAULT_BUNDLE_ID'" 2>/dev/null | head -n1)"
fi
[[ -n "$CHROME_APP" && -d "$CHROME_APP" ]] || die "Google Chrome not found. Install it, then re-run."

BUNDLE_ID="$(/usr/bin/defaults read "$CHROME_APP/Contents/Info" CFBundleIdentifier 2>/dev/null || echo "$DEFAULT_BUNDLE_ID")"
ok "Found Chrome: $CHROME_APP  ($BUNDLE_ID)"

# Bundle IDs are stored lowercased in the LSHandlers array.
BUNDLE_ID_LC="$(printf '%s' "$BUNDLE_ID" | tr '[:upper:]' '[:lower:]')"

# --- Step 2: (re)register Chrome with Launch Services -----------------------
# Ensures Chrome's declared URL schemes (including mailto, once enabled) are
# known to LS. Harmless if already registered.
if [[ -x "$LSREGISTER" ]]; then
  "$LSREGISTER" -f "$CHROME_APP" >/dev/null 2>&1 && log "Re-registered Chrome with Launch Services."
fi

# --- Helper: read the current effective mailto handler ----------------------
current_handler() {
  if [[ -n "$SWIFT_BIN" ]]; then
    "$SWIFT_BIN" - "$SCHEME" 2>/dev/null <<'SWIFT'
import AppKit
let scheme = CommandLine.arguments[1]
guard let url = URL(string: "\(scheme):probe@example.com"),
      let app = NSWorkspace.shared.urlForApplication(toOpen: url),
      let id  = Bundle(url: app)?.bundleIdentifier else { exit(0) }
print(id.lowercased())
SWIFT
  else
    # Best-effort read straight from the secure plist.
    /usr/libexec/PlistBuddy -c "Print :LSHandlers" "$SECURE_PLIST" 2>/dev/null \
      | awk -v s="$SCHEME" '
          /Dict/ {inblk=1; role=""; sch=""}
          inblk && $1=="LSHandlerURLScheme" {sch=$3}
          inblk && $1=="LSHandlerRoleAll"   {role=$3}
          /\}/ { if (inblk && sch==s && role!="") print role; inblk=0 }'
  fi
}

is_done() {
  local cur; cur="$(current_handler | tr '[:upper:]' '[:lower:]' | head -n1)"
  [[ "$cur" == "$BUNDLE_ID_LC" ]]
}

flush_ls() {
  # Make Launch Services / cfprefsd re-read the on-disk plist.
  /usr/bin/killall cfprefsd >/dev/null 2>&1 || true
  [[ -x "$LSREGISTER" ]] && "$LSREGISTER" -f "$CHROME_APP" >/dev/null 2>&1 || true
  sleep 1
}

# --- Step 3: primary path — NSWorkspace API ---------------------------------
set_via_api() {
  [[ -n "$SWIFT_BIN" ]] || return 1
  "$SWIFT_BIN" - "$BUNDLE_ID" "$SCHEME" <<'SWIFT'
import AppKit
let bundleID = CommandLine.arguments[1]
let scheme   = CommandLine.arguments[2]
guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) else {
    FileHandle.standardError.write("not installed: \(bundleID)\n".data(using: .utf8)!); exit(2)
}
let group = DispatchGroup(); group.enter(); var failed = false
NSWorkspace.shared.setDefaultApplication(at: appURL, toOpenURLsWithScheme: scheme) { error in
    if let error = error {
        FileHandle.standardError.write("LS error: \(error.localizedDescription)\n".data(using: .utf8)!)
        failed = true
    }
    group.leave()
}
if group.wait(timeout: .now() + 5) == .timedOut {
    FileHandle.standardError.write("timed out\n".data(using: .utf8)!); exit(3)
}
exit(failed ? 1 : 0)
SWIFT
}

# --- Step 4: fallback — rewrite secure.plist LSHandlers directly -------------
set_via_plist_swift() {
  [[ -n "$SWIFT_BIN" ]] || return 1
  "$SWIFT_BIN" - "$SECURE_PLIST" "$SCHEME" "$BUNDLE_ID_LC" <<'SWIFT'
import Foundation
let path    = CommandLine.arguments[1]
let scheme  = CommandLine.arguments[2]
let handler = CommandLine.arguments[3]
let url = URL(fileURLWithPath: path)
var root: [String: Any] = [:]
if let data = try? Data(contentsOf: url),
   let plist = (try? PropertyListSerialization.propertyList(from: data, options: [], format: nil)) as? [String: Any] {
    root = plist
}
var handlers = (root["LSHandlers"] as? [[String: Any]]) ?? []
handlers.removeAll { ($0["LSHandlerURLScheme"] as? String)?.lowercased() == scheme }
handlers.append([
    "LSHandlerURLScheme": scheme,
    "LSHandlerRoleAll": handler,
    "LSHandlerPreferredVersions": ["LSHandlerRoleAll": "-"],
])
root["LSHandlers"] = handlers
do {
    try FileManager.default.createDirectory(at: url.deletingLastPathComponent(),
                                            withIntermediateDirectories: true)
    let out = try PropertyListSerialization.data(fromPropertyList: root, format: .binary, options: 0)
    try out.write(to: url)
} catch {
    FileHandle.standardError.write("write failed: \(error)\n".data(using: .utf8)!); exit(1)
}
SWIFT
}

set_via_plist_plistbuddy() {
  local pb="/usr/libexec/PlistBuddy"
  mkdir -p "$(dirname "$SECURE_PLIST")"
  [[ -f "$SECURE_PLIST" ]] || "$pb" -c "Add :LSHandlers array" "$SECURE_PLIST" >/dev/null 2>&1

  # Find an existing entry index for this scheme.
  local idx=0 found=-1
  while "$pb" -c "Print :LSHandlers:$idx:LSHandlerURLScheme" "$SECURE_PLIST" >/dev/null 2>&1; do
    local s; s="$("$pb" -c "Print :LSHandlers:$idx:LSHandlerURLScheme" "$SECURE_PLIST" 2>/dev/null)"
    [[ "$s" == "$SCHEME" ]] && { found=$idx; break; }
    idx=$((idx+1))
  done

  if [[ $found -ge 0 ]]; then
    "$pb" -c "Set :LSHandlers:$found:LSHandlerRoleAll $BUNDLE_ID_LC" "$SECURE_PLIST" >/dev/null 2>&1
  else
    "$pb" -c "Add :LSHandlers: dict" "$SECURE_PLIST" >/dev/null 2>&1
    local new; new=$idx
    "$pb" -c "Add :LSHandlers:$new:LSHandlerURLScheme string $SCHEME" "$SECURE_PLIST" >/dev/null 2>&1
    "$pb" -c "Add :LSHandlers:$new:LSHandlerRoleAll string $BUNDLE_ID_LC" "$SECURE_PLIST" >/dev/null 2>&1
    "$pb" -c "Add :LSHandlers:$new:LSHandlerPreferredVersions dict" "$SECURE_PLIST" >/dev/null 2>&1
    "$pb" -c "Add :LSHandlers:$new:LSHandlerPreferredVersions:LSHandlerRoleAll string -" "$SECURE_PLIST" >/dev/null 2>&1
  fi
}

# --- Orchestration ----------------------------------------------------------
if is_done; then
  ok "Chrome is already the default $SCHEME handler. Nothing to do."
  exit 0
fi

log "Attempt 1/2: NSWorkspace API…"
if set_via_api; then
  sleep 1
  is_done && { ok "Chrome set as default $SCHEME handler (API)."; echo "Verify: open mailto:test@example.com"; exit 0; }
  warn "API call returned success but handler not yet confirmed; flushing…"
  flush_ls
  is_done && { ok "Chrome set as default $SCHEME handler (API, after flush)."; echo "Verify: open mailto:test@example.com"; exit 0; }
fi

log "Attempt 2/2: direct secure.plist rewrite…"
if [[ -n "$SWIFT_BIN" ]]; then
  set_via_plist_swift || warn "Swift plist rewrite reported an error."
else
  set_via_plist_plistbuddy || warn "PlistBuddy rewrite reported an error."
fi
flush_ls

if is_done; then
  ok "Chrome set as default $SCHEME handler (plist fallback)."
  echo "Verify: open mailto:test@example.com"
  exit 0
fi

# --- Both paths failed ------------------------------------------------------
cat >&2 <<EOF
✗ Could not confirm Chrome as the default $SCHEME handler.

Most likely cause: Chrome has not registered itself as a mailto handler yet.
Chrome only declares the mailto scheme after you enable a web mail handler:

  1. Open Gmail in Chrome.
  2. Click the handler (⛓/double-diamond) icon in the address bar, OR go to
     chrome://settings/handlers and allow mail.google.com to open mailto links.
  3. Re-run this script.

Current handler: $(current_handler | head -n1 || echo '(unknown)')
EOF
exit 1
bash

If you manage a fleet where everyone’s mail lives in the browser, this pairs nicely with the Chrome policy push and quietly removes one of those papercuts nobody ever files a ticket about. They just sigh and re-type the address into Gmail.

🔗
Links
Setting Chrome as the Default Mail Handler on a Mac Fleet
https://barnes.tech/blog/setting-chrome-as-the-default-mail-handler-on-a-mac-fleet
AuthorBarnes Tech Blog
Published atJune 11, 2026