Private/Accounts.ps1

# Accounts: named sets of SMTP settings kept in accounts.json, like the accounts of msmtp. An account
# is self-contained: while one is in use, the SMTP_* environment variables are ignored (its password
# may still come from SMTP_PASS_<ACCOUNT>, see PasswordStore.ps1).

$script:AccountSettingNames = @('Server', 'Port', 'User', 'From', 'FromName', 'To', 'Cc', 'Bcc', 'ReplyTo')
$script:AddressSettingNames = @('To', 'Cc', 'Bcc', 'ReplyTo')
# Matched telling case apart (-cnotmatch, and Options None in the ValidatePattern of Set-MailAccount), as it names
# both: ignoring case as the culture does, it took a dotless i in a session in Turkish and a dotted capital I in any
# session, and the file such a name went to was then refused in sessions in other cultures (measured)
$script:AccountNamePattern = '^[A-Za-z0-9][A-Za-z0-9_.-]*$'
$script:DefaultServer = 'smtp.gmail.com'
$script:DefaultPort = 587

# Account names are keys of dictionaries, where PowerShell gives a key precedence over a property of the
# same name: an account called "Keys" would hide .Keys. So the names are always listed with .psbase.Keys.

# Whether an account name matches a wildcard pattern, ignoring case alike in every culture. -like ignores case as
# the culture of the session does, and in Turkish "I" is not the capital of "i": "work-inbox" missed the account
# "WORK-INBOX" (measured). Elsewhere the module compares text only in the forms that tell case apart (-cmatch,
# -creplace, -csplit) or with -eq and -in, which no culture changes; Tests/Culture.Tests.ps1 reads the code and fails
# on any other. A pattern that is not valid ("work[") throws with the message of PowerShell, as -like did, rather than
# with the 'Exception calling "IsMatch"' that wraps it.
function Test-Wildcard([string]$Text, [string]$Pattern) {
    try {
        return [WildcardPattern]::new($Pattern, [System.Management.Automation.WildcardOptions]'IgnoreCase, CultureInvariant').IsMatch($Text)
    } catch [System.Management.Automation.MethodInvocationException] {
        throw $_.Exception.InnerException
    }
}

# The folder of accounts.json (and, outside Windows, of the password files): SMTP_CONFIG_DIR, or else
# ElektoMailPosh in the user's configuration folder (%APPDATA% on Windows; XDG_CONFIG_HOME, or else ~/.config, on
# Linux and macOS)
function Get-AccountDirectory {
    # A relative SMTP_CONFIG_DIR is resolved here against the current location: the .NET file calls that
    # keep the passwords would resolve it against the process folder, which Set-Location does not change
    if ($env:SMTP_CONFIG_DIR) { return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($env:SMTP_CONFIG_DIR) }
    if ($IsWindows) { return Get-ApplicationDataDirectory }
    # Linux and macOS alike, as command-line tools do, and as ElektoImapPosh does: since .NET 8, ApplicationData is
    # ~/Library/Application Support on macOS. On Linux this is what .NET gives (XDG_CONFIG_HOME, when an absolute path).
    $configuration = $env:XDG_CONFIG_HOME
    if (-not $configuration -or -not [IO.Path]::IsPathRooted($configuration)) { $configuration = Join-Path $HOME '.config' }
    $directory = Join-Path $configuration 'ElektoMailPosh'
    # 2.0.0 to 2.0.3 kept it where .NET puts ApplicationData, which on macOS is ~/Library/Application Support: that
    # folder, with its password files, is still the one while it has an accounts file and the new one has none
    $previous = Get-ApplicationDataDirectory
    if ($previous -ne $directory -and -not (Test-Path -LiteralPath (Join-Path $directory 'accounts.json') -PathType Leaf) -and
        (Test-Path -LiteralPath (Join-Path $previous 'accounts.json') -PathType Leaf)) {
        return $previous
    }
    return $directory
}

# ElektoMailPosh in the folder .NET gives for ApplicationData: %APPDATA% on Windows, ~/Library/Application Support
# on macOS. Without DoNotVerify, a folder that does not exist yet (~/.config of a new Linux user, a container) comes
# back as '', and even sending by the SMTP_* variables would fail.
function Get-ApplicationDataDirectory {
    return Join-Path ([Environment]::GetFolderPath('ApplicationData', 'DoNotVerify')) 'ElektoMailPosh'
}

function Get-AccountFilePath {
    return Join-Path (Get-AccountDirectory) 'accounts.json'
}

# A list of addresses from a string (which may itself be a comma-separated list) or an array of them
function ConvertTo-AddressList($Value) {
    return ,[string[]]@($Value | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
}

# The settings in the SMTP_* environment variables, with the defaults Send-Mail has always applied
function Get-EnvironmentConfiguration {
    return @{
        Server   = if ($env:SMTP_SERVER) { $env:SMTP_SERVER } else { $script:DefaultServer }
        Port     = if ($env:SMTP_PORT) { [int]$env:SMTP_PORT } else { $script:DefaultPort }
        User     = $env:SMTP_USER
        Password = $env:SMTP_PASS
        From     = $env:SMTP_FROM
        FromName = $env:SMTP_FROM_NAME
        To       = ConvertTo-AddressList $env:SMTP_TO
        Cc       = ConvertTo-AddressList $env:SMTP_CC
        Bcc      = ConvertTo-AddressList $env:SMTP_BCC
        ReplyTo  = ConvertTo-AddressList $env:SMTP_REPLY_TO
    }
}

# Reads the accounts file into Path, Default (an account name or $null) and Accounts (name -> settings,
# both looked up ignoring case). Returns $null when there is no file; throws when it is not valid.
# ConvertFrom-Json keeps the case of the keys, and keeps apart keys that differ only in case. The rest
# of the module ignores case (as do the Credential Manager and the SMTP_PASS_<ACCOUNT> variables), so
# the keys, account names and settings of the file are read ignoring case too, and two that differ only
# in case are an error rather than one of them lost.
function Read-AccountFile {
    $path = Get-AccountFilePath
    if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $null }

    # A file that cannot be read stops the command: taken as empty, Send-Mail went back to the SMTP_* variables, and
    # Set-MailAccount wrote the new account over the others
    try {
        $text = Get-Content -LiteralPath $path -Raw -ErrorAction Stop
    } catch {
        throw "Could not read the accounts file '$path': $($_.Exception.Message)"
    }
    try {
        $json = $text | ConvertFrom-Json -AsHashtable
    } catch {
        throw "The accounts file '$path' is not valid JSON: $($_.Exception.Message)"
    }
    if ($null -eq $json) { $json = @{} }
    if ($json -isnot [System.Collections.IDictionary]) { throw "The accounts file '$path' must hold a JSON object." }
    $data = @{}
    foreach ($entry in $json.GetEnumerator()) {
        $key = 'Default', 'Accounts' | Where-Object { $_ -eq $entry.Key }
        if (-not $key) { throw "Unknown key '$($entry.Key)' in the accounts file '$path'." }
        if ($data.ContainsKey($key)) { throw "The key '$key' is twice in the accounts file '$path', in different case." }
        $data[$key] = $entry.Value
    }

    $accounts = [ordered]@{}
    if ($null -ne $data.Accounts) {
        if ($data.Accounts -isnot [System.Collections.IDictionary]) { throw "'Accounts' in '$path' must be a JSON object." }
        foreach ($entry in $data.Accounts.GetEnumerator()) {
            $name = $entry.Key
            if ($name -cnotmatch $script:AccountNamePattern) {
                throw "Invalid account name '$name' in '$path': use letters from A to Z, digits, '.', '-' and '_', starting with a letter or digit."
            }
            $sameName = @($accounts.psbase.Keys | Where-Object { $_ -eq $name })
            if ($sameName) { throw "The accounts '$($sameName[0])' and '$name' in '$path' differ only in case, which makes them the same account: rename or remove one." }
            $sameVariable = Find-PasswordVariableOwner $accounts.psbase.Keys $name
            if ($sameVariable) { throw "The accounts '$sameVariable' and '$name' in '$path' would share the password variable $(Get-PasswordVariableName $name), which would give one the password of the other: rename one." }
            if ($entry.Value -isnot [System.Collections.IDictionary]) { throw "Account '$name' in '$path' must be a JSON object." }

            $settings = [ordered]@{}
            foreach ($setting in $entry.Value.GetEnumerator()) {
                $key = $script:AccountSettingNames | Where-Object { $_ -eq $setting.Key }
                if (-not $key) {
                    throw "Unknown setting '$($setting.Key)' in account '$name' of '$path'. Settings: $($script:AccountSettingNames -join ', ')."
                }
                if ($settings.Contains($key)) { throw "The setting '$key' is twice in account '$name' of '$path', in different case." }
                $value = $setting.Value
                if ($key -eq 'Port') {
                    if (($value -isnot [int] -and $value -isnot [long]) -or $value -lt 1 -or $value -gt 65535) { throw "Port of account '$name' in '$path' must be a number from 1 to 65535." }
                    $value = [int]$value
                } elseif ($key -in $script:AddressSettingNames) {
                    $value = ConvertTo-AddressList $value
                } elseif ($value -isnot [string]) {
                    throw "$key of account '$name' in '$path' must be a string."
                }
                $settings[$key] = $value
            }
            if (-not $settings.Server -or -not $settings.User) { throw "Account '$name' in '$path' needs both Server and User." }
            $accounts[$name] = $settings
        }
    }

    $default = $data.Default
    if ($default) {
        $match = @($accounts.psbase.Keys | Where-Object { $_ -eq $default })
        if (-not $match) { throw "The default account '$default' is not one of the accounts in '$path'." }
        $default = $match[0]
    } else {
        $default = $null
    }

    return @{ Path = $path; Default = $default; Accounts = $accounts }
}

# The account of $Names whose password variable (SMTP_PASS_<ACCOUNT>, where all but letters and digits become '_')
# would be that of $Name, such as work.prod for work-prod; or $null
function Find-PasswordVariableOwner($Names, [string]$Name) {
    $variable = Get-PasswordVariableName $Name
    return @($Names | Where-Object { (Get-PasswordVariableName $_) -eq $variable })[0]
}

# Writes the accounts file, settings in a fixed order and without the empty ones. Any failure stops the caller here,
# before it stores or deletes a password that would then not match: an exception of .NET would otherwise end only its
# own statement, and the command went on as if the file were written (measured with the rename, which also says now
# what failed). $ErrorActionPreference covers the other calls of .NET here, and any added later.
function Write-AccountFile([string]$Default, [System.Collections.IDictionary]$Accounts) {
    $ErrorActionPreference = 'Stop'
    $path = Get-AccountFilePath
    $data = [ordered]@{}
    if ($Default) { $data.Default = $Default }
    $data.Accounts = [ordered]@{}
    foreach ($name in $Accounts.psbase.Keys) {
        $settings = [ordered]@{}
        foreach ($key in $script:AccountSettingNames) {
            $value = $Accounts[$name][$key]
            if ($key -in $script:AddressSettingNames) {
                $value = ConvertTo-AddressList $value
                if ($value.Count -eq 0) { continue }
                # A single address is written as a string, which reads better than a one-item array
                if ($value.Count -eq 1) { $value = $value[0] }
            } elseif ($null -eq $value -or "$value" -eq '') {
                continue
            }
            $settings[$key] = $value
        }
        $data.Accounts[$name] = $settings
    }

    New-Item -ItemType Directory -Path (Split-Path -Path $path) -Force -ErrorAction Stop | Out-Null
    # When accounts.json is a link, as GNU stow makes one into a dotfiles repository, the file it finally points to is
    # the one written: the rename below, over the link, would put a regular file in its place, and the file in the
    # repository would get no more changes, without a word (measured)
    if (Test-Path -LiteralPath $path) {
        $target = [IO.File]::ResolveLinkTarget($path, $true)
        if ($target) { $path = $target.FullName }
    }
    # Written beside it and then put in its place (a rename, atomic), so a write that fails or is interrupted
    # leaves the previous file whole: written in place, the file was emptied first, and a write that then failed
    # left it with no accounts (measured). Outside Windows, the new file has the mode of the one it replaces from
    # the start, before anything is written: a file the user made owner-only is never readable by others, not even
    # halfway. The umask can only take bits from the mode it is created with, so it is set again.
    $temporary = "$path.$([guid]::NewGuid().ToString('N')).tmp"
    try {
        if (-not $IsWindows -and (Test-Path -LiteralPath $path)) {
            $mode = [IO.File]::GetUnixFileMode($path)
            [IO.FileStream]::new($temporary, [IO.FileStreamOptions]@{ Mode = 'CreateNew'; Access = 'Write'; UnixCreateMode = $mode }).Dispose()
            [IO.File]::SetUnixFileMode($temporary, $mode)
        }
        Set-Content -LiteralPath $temporary -Value ($data | ConvertTo-Json -Depth 5) -Encoding utf8NoBOM -ErrorAction Stop
        # On Windows a file that another program holds open cannot be replaced, and .NET then says only "Access to the
        # path is denied" (measured in a Windows Sandbox), which reads as a matter of permissions
        try {
            [IO.File]::Move($temporary, $path, $true)
        } catch {
            $reason = if ($_.Exception.InnerException) { $_.Exception.InnerException.Message } else { $_.Exception.Message }
            $hint = if ($IsWindows) { ' Another program, such as an editor or a sync client, may have it open.' } else { '' }
            throw "Could not replace the accounts file '$path': $reason$hint"
        }
    } finally {
        if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Force -ErrorAction Ignore }
    }
}

# The name of an account as the file spells it, or $null when the file has no such account
function Find-AccountName($File, [string]$Name) {
    if (-not $File) { return $null }
    return @($File.Accounts.psbase.Keys | Where-Object { $_ -eq $Name })[0]
}

# The settings Send-Mail uses: those of the account given, or of the default account of the file, or
# else the SMTP_* environment variables, as before accounts existed. Throws when the account does not
# exist or has no password.
function Resolve-MailConfiguration([string]$Account) {
    $file = Read-AccountFile
    if (-not $Account -and $file) { $Account = $file.Default }

    if (-not $Account) {
        $settings = Get-EnvironmentConfiguration
        $settings.Account = $null
        $settings.PasswordSource = 'SMTP_PASS'
        return $settings
    }

    if (-not $file) { throw "There is no account '$Account': the accounts file '$(Get-AccountFilePath)' does not exist." }
    $name = Find-AccountName $file $Account
    if (-not $name) { throw "There is no account '$Account' in '$($file.Path)'. Accounts: $($file.Accounts.psbase.Keys -join ', ')." }

    $password = Get-AccountPassword $name
    if (-not $password) { throw (Get-MissingPasswordMessage $name) }

    $accountSettings = $file.Accounts[$name]
    return @{
        Account        = $name
        Server         = $accountSettings.Server
        Port           = if ($accountSettings.Port) { $accountSettings.Port } else { $script:DefaultPort }
        User           = $accountSettings.User
        Password       = $password.Password
        PasswordSource = $password.Source
        From           = $accountSettings.From
        FromName       = $accountSettings.FromName
        To             = ConvertTo-AddressList $accountSettings.To
        Cc             = ConvertTo-AddressList $accountSettings.Cc
        Bcc            = ConvertTo-AddressList $accountSettings.Bcc
        ReplyTo        = ConvertTo-AddressList $accountSettings.ReplyTo
    }
}