/**
* mcp/skills/checkMailAccountConfig.ts — check_mail_account_config skill
*
* Validates email account configuration by checking IMAP/SMTP server
* connectivity and settings in Apple Mail or Outlook.
*
* Platform strategy
* -----------------
* darwin Mail: enumerate accounts via Mail's AppleScript interface (JXA).
* Modern macOS (since ~OS X Yosemite) no longer stores accounts
* in ~/Library/Mail/.../Accounts.plist — they live in the system
* Accounts DB owned by accountsd and are exposed through Mail's
* scripting dictionary. This also covers OAuth providers (Gmail,
* Microsoft 365) added via Internet Accounts and needs only the
* Apple Events automation grant (Tier-1 auto-prompt), not FDA.
* darwin Outlook: read Outlook 15 Profiles via plutil
* win32: PowerShell check Outlook profile via registry
*
* Smoke test
* npx tsx -r dotenv/config mcp/skills/checkMailAccountConfig.ts
*/
import * as os from "os" ;
import * as nodePath from "path" ;
import { exec } from "child_process" ;
import { promisify } from "util" ;
import { z } from "zod" ;
import * as fs from "fs/promises" ;
const execAsync = promisify (exec);
// -- Meta ---------------------------------------------------------------------
export const meta = {
name: "check_mail_account_config" ,
description:
"Validates email account configuration by checking IMAP/SMTP server " +
"connectivity and settings in Apple Mail or Outlook. Use at the start of " +
"any email troubleshooting workflow." ,
riskLevel: "low" ,
destructive: false ,
requiresConsent: false ,
supportsDryRun: false ,
affectedScope: [ "user" ],
auditRequired: false ,
// No tccCategories: the macOS Mail path uses Apple Events (Tier-1 auto-prompt),
// and the Outlook path reads the user's own Group Container (not TCC-gated).
// Declaring FullDiskAccess here would make G4's preflight abort the run when
// FDA is absent — needlessly, since neither path requires it.
schema: {
client: z
. enum ([ "mail" , "outlook" , "auto" ])
. optional ()
. describe ( "Email client to check. auto=detect installed client. Default: auto" ),
account: z
. string ()
. optional ()
. describe ( "Email address to check. Omit to list all configured accounts" ),
},
} as const ;
// -- Types --------------------------------------------------------------------
interface AccountInfo {
email : string ;
imapServer : string | null ;
smtpServer : string | null ;
port : number | null ;
ssl : boolean | null ;
}
type DetectedClient = "mail" | "outlook" | "unknown" ;
// -- PowerShell helper --------------------------------------------------------
async function runPS ( script : string ) : Promise < string > {
const encoded = Buffer. from (script, "utf16le" ). toString ( "base64" );
const { stdout } = await execAsync (
`powershell.exe -NoProfile -NonInteractive -EncodedCommand ${ encoded }` ,
{ maxBuffer: 10 * 1024 * 1024 },
);
return stdout. trim ();
}
// -- darwin helpers -----------------------------------------------------------
async function readPlistAsJson ( plistPath : string ) : Promise < Record < string , unknown > | null > {
try {
const { stdout } = await execAsync (
`plutil -convert json -o - '${ plistPath . replace ( / ' / g , `' \\ ''` ) }' 2>/dev/null` ,
{ maxBuffer: 5 * 1024 * 1024 },
);
return JSON . parse (stdout) as Record < string , unknown >;
} catch {
return null ;
}
}
// JXA enumerated against Mail's scripting dictionary. Returns a JSON array of
// account records, or {error} if Mail itself rejects the request. Per-property
// try/catch keeps one unreadable field from dropping the whole account.
const MAIL_JXA = `
(() => {
const Mail = Application("Mail");
let accts;
try { accts = Mail.accounts(); } catch (e) { return JSON.stringify({ error: String(e) }); }
const out = [];
for (const a of accts) {
let enabled = true; try { enabled = a.enabled(); } catch (e) {}
if (enabled === false) continue;
let emails = []; try { emails = a.emailAddresses() || []; } catch (e) {}
let userName = ""; try { userName = a.userName() || ""; } catch (e) {}
let server = null; try { server = a.serverName(); } catch (e) {}
let port = null; try { port = a.port(); } catch (e) {}
let ssl = null; try { ssl = a.usesSsl(); } catch (e) {}
let smtp = null; try { const d = a.deliveryAccount(); if (d) smtp = d.serverName(); } catch (e) {}
out.push({
email: emails[0] || userName || null,
imapServer: server || null,
smtpServer: smtp || null,
port: (typeof port === "number") ? port : null,
ssl: (typeof ssl === "boolean") ? ssl : null,
});
}
return JSON.stringify(out);
})();
` ;
async function runJxa ( script : string ) : Promise < string > {
const tmp = nodePath. join (os. tmpdir (), `mailcfg-${ process . pid }-${ Date . now () }.js` );
await fs. writeFile (tmp, script, "utf8" );
try {
const { stdout } = await execAsync ( `osascript -l JavaScript '${ tmp }'` , {
timeout: 30_000 ,
maxBuffer: 5 * 1024 * 1024 ,
});
return stdout. trim ();
} finally {
await fs. unlink (tmp). catch (() => undefined );
}
}
async function getMailAccounts ( filterEmail ?: string ) : Promise <{ accounts : AccountInfo []; error : string | null }> {
let raw : string ;
try {
raw = await runJxa ( MAIL_JXA );
} catch (err) {
// Never silently swallow an authorization failure — surface it so the
// response can guide the user to grant the Automation permission.
const msg = (err as { stderr ?: string ; message ?: string }).stderr
|| (err instanceof Error ? err.message : String (err));
if ( / -1743 | Not authori [sz] ed | not allowed to send Apple events / i . test (msg)) {
return {
accounts: [],
error: "Automation permission required: allow this app to control Mail "
+ "(System Settings → Privacy & Security → Automation), then retry." ,
};
}
return { accounts: [], error: `Could not read Mail accounts: ${ msg . split ( " \n " )[ 0 ] }` };
}
if ( ! raw) return { accounts: [], error: null };
let parsed : unknown ;
try {
parsed = JSON . parse (raw);
} catch {
return { accounts: [], error: null };
}
if (parsed && typeof parsed === "object" && ! Array. isArray (parsed) && "error" in parsed) {
return { accounts: [], error: String ((parsed as { error : unknown }).error) };
}
const rawAccounts = Array. isArray (parsed) ? (parsed as Record < string , unknown >[]) : [];
const accounts : AccountInfo [] = [];
for ( const acct of rawAccounts) {
const email = String (acct[ "email" ] ?? "" );
if ( ! email) continue ;
if (filterEmail && ! email. toLowerCase (). includes (filterEmail. toLowerCase ())) continue ;
accounts. push ({
email,
imapServer: (acct[ "imapServer" ] as string ) || null ,
smtpServer: (acct[ "smtpServer" ] as string ) || null ,
port: typeof acct[ "port" ] === "number" ? (acct[ "port" ] as number ) : null ,
ssl: typeof acct[ "ssl" ] === "boolean" ? (acct[ "ssl" ] as boolean ) : null ,
});
}
return { accounts, error: null };
}
async function getOutlookAccountsDarwin ( filterEmail ?: string ) : Promise < AccountInfo []> {
const profileBase = nodePath. join (
os. homedir (),
"Library" ,
"Group Containers" ,
"UBF8T346G9.Office" ,
"Outlook" ,
"Outlook 15 Profiles" ,
);
let profileDirs : string [] = [];
try {
profileDirs = await fs. readdir (profileBase);
} catch {
return [];
}
const accounts : AccountInfo [] = [];
for ( const profileDir of profileDirs) {
const plistPath = nodePath. join (profileBase, profileDir, "Account Profile.plist" );
const data = await readPlistAsJson (plistPath);
if ( ! data) continue ;
const accts = (data[ "Accounts" ] ?? []) as Record < string , unknown >[];
for ( const acct of accts) {
const email = String (acct[ "Email Address" ] ?? acct[ "AccountEmailAddress" ] ?? "" );
const imapServer = String (acct[ "IMAP Server" ] ?? acct[ "Hostname" ] ?? "" );
const smtpServer = String (acct[ "SMTP Server" ] ?? "" );
const port = acct[ "IMAP Port" ] ? Number (acct[ "IMAP Port" ]) : null ;
const ssl = acct[ "Use SSL" ] !== undefined ? Boolean (acct[ "Use SSL" ]) : null ;
if ( ! email) continue ;
if (filterEmail && ! email. toLowerCase (). includes (filterEmail. toLowerCase ())) continue ;
accounts. push ({ email, imapServer: imapServer || null , smtpServer: smtpServer || null , port, ssl });
}
}
return accounts;
}
// NOTE: client detection on darwin is intentionally NOT by app presence.
// Mail.app ALWAYS exists on macOS (system app at /System/Applications), so its
// presence cannot distinguish a Mail user from an Outlook user — presence-based
// detection resolved `client` to "mail" for every Mac and left the Outlook path
// unreachable (e.g. for an Outlook user whose Mail.app has no accounts). run()
// instead probes BOTH clients and resolves to whichever has accounts configured.
// -- win32 implementation -----------------------------------------------------
async function getAccountsWin32 ( filterEmail ?: string ) : Promise < AccountInfo []> {
const ps = `
$ErrorActionPreference = 'SilentlyContinue'
$profiles = Get-ChildItem 'HKCU: \\ Software \\ Microsoft \\ Office \\ 16.0 \\ Outlook \\ Profiles' -ErrorAction SilentlyContinue
$results = @()
foreach ($profile in $profiles) {
$accounts = Get-ChildItem $profile.PSPath -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.GetValue('Account Name') -ne $null }
foreach ($acct in $accounts) {
$results += [PSCustomObject]@{
email = [string]( if ($acct.GetValue('Email')) { $acct.GetValue('Email') } elseif ($acct.GetValue('Account Name')) { $acct.GetValue('Account Name') } else { '' } )
imapServer = [string]( if ($acct.GetValue('IMAP Server')) { $acct.GetValue('IMAP Server') } else { '' } )
smtpServer = [string]( if ($acct.GetValue('SMTP Server')) { $acct.GetValue('SMTP Server') } else { '' } )
port = $null
ssl = $null
}
}
}
$results | ConvertTo-Json -Depth 2 -Compress` . trim ();
const raw = await runPS (ps);
if ( ! raw) return [];
let all : AccountInfo [];
try {
const parsed = JSON . parse (raw) as AccountInfo | AccountInfo [];
all = (Array. isArray (parsed) ? parsed : [parsed])
. filter ( a => a.email);
} catch {
return [];
}
if (filterEmail) {
return all. filter ( a => a.email. toLowerCase (). includes (filterEmail. toLowerCase ()));
}
return all;
}
// -- Exported run function ----------------------------------------------------
export async function run ({
client = "auto" ,
account ,
} : {
client ?: "mail" | "outlook" | "auto" ;
account ?: string ;
} = {}) {
const platform = os. platform ();
if (platform === "win32" ) {
const accounts = await getAccountsWin32 (account);
return { client: "outlook" , platform, accounts };
}
// macOS. In "auto" mode, resolve the client by which one actually has accounts
// configured — NOT by app presence (Mail.app always exists on macOS, so
// presence always picked "mail" and left "outlook" unreachable).
let resolvedClient : DetectedClient ;
let accounts : AccountInfo [] = [];
let mailError : string | null = null ;
if (client === "mail" ) {
const r = await getMailAccounts (account);
accounts = r.accounts;
mailError = r.error;
resolvedClient = "mail" ;
} else if (client === "outlook" ) {
accounts = await getOutlookAccountsDarwin (account);
resolvedClient = "outlook" ;
} else {
// auto: probe BOTH, resolve to whichever has accounts.
const mail = await getMailAccounts (account);
const outlookAccounts = await getOutlookAccountsDarwin (account);
accounts = [ ... mail.accounts, ... outlookAccounts];
mailError = mail.error;
resolvedClient = mail.accounts. length > 0
? "mail"
: outlookAccounts. length > 0 ? "outlook" : "unknown" ;
}
return { client: resolvedClient, platform, accounts, ... (mailError ? { error: mailError } : {}) };
}
// -- Smoke test ---------------------------------------------------------------
if ( false ) {
run ({})
. then ( r => console. log ( JSON . stringify (r, null , 2 )))
. catch (( err : Error ) => { console. error (err.message); process. exit ( 1 ); });
}