<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\models;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library\CredentialCipher;
/**
* Persists per-user mail account records used by the Mail activity
* when MAIL_MODE allows external accounts. Each row records the
* connection settings for one remote mail service the user has
* registered (Gmail, Fastmail, a corporate server, etc.): the
* incoming (IMAP) settings, and the outgoing (SMTP) settings.
*
* Passwords are encrypted at rest via CredentialCipher (libsodium
* sodium_crypto_secretbox); both the IMAP password and the SMTP
* password get their own nonce + ciphertext pair, sharing the
* master key. The encrypted payloads sit in this (public) table;
* the master key sits in the private database. Reads decrypt
* lazily; a password plaintext is only resurrected when a caller
* explicitly requests it via getAccountWithPassword() (IMAP) or
* getAccountWithSmtpPassword() (SMTP).
*
* All public methods are user-scoped: the caller passes $user_id and
* the model refuses to surface rows belonging to any other user. This
* is the access-control boundary; controllers should not bypass it.
*
* @author Chris Pollett
*/
class MailAccountModel extends Model
{
/**
* Cached master encryption key (raw 32 bytes), loaded from the
* private database on first use and reused for the rest of the
* request so repeated encrypt/decrypt calls do not re-query.
* @var string|null
*/
protected $master_key = null;
/**
* Placeholder string shown in a password field on the edit form
* when a credential is already stored for that section. It is a
* fixed run of bullet characters: the real password is never
* decrypted into the HTML. When the form is submitted, a
* password field whose value is exactly this sentinel is
* treated as "leave the stored credential unchanged" (same as
* an empty field). Any other non-empty value is a real change.
* @var string
*/
const PASSWORD_SENTINEL = "\u{2022}\u{2022}\u{2022}\u{2022}" .
"\u{2022}\u{2022}\u{2022}\u{2022}";
/**
* Per-provider preset map. The keys are the provider names stored
* in the MAIL_ACCOUNT.PROVIDER column; "Custom" is implicit (the
* absence of any value in PROVIDER, surfaced as "Custom" in the
* dropdown). For each preset:
* - imap_host / imap_port / imap_tls and the smtp_* siblings
* are the well-known public endpoints the provider operates;
* - email_domain is set only when the provider's IMAP/SMTP
* login accepts a bare username (no @), in which case a
* bare-username input is stitched to <username>@<domain>
* for the From address. Gmail is currently the only entry
* that supports this; everyone else stores null and the
* username field must contain a full email address;
* - login_label is 'username' or 'email' and drives only the
* visible field label on the create-account form. It's a
* cue to the user, not a validator -- the server accepts
* whatever the form posts and trusts the resulting login
* attempt to fail informatively if it's wrong.
* @var array
*/
const PROVIDER_PRESETS = [
"Gmail" => [
'imap_host' => 'imap.gmail.com',
'imap_port' => 993,
'imap_tls' => 'imaps',
'smtp_host' => 'smtp.gmail.com',
'smtp_port' => 587,
'smtp_tls' => 'starttls',
'email_domain' => 'gmail.com',
'login_label' => 'username',
],
"Outlook.com" => [
'imap_host' => 'outlook.office365.com',
'imap_port' => 993,
'imap_tls' => 'imaps',
'smtp_host' => 'smtp-mail.outlook.com',
'smtp_port' => 587,
'smtp_tls' => 'starttls',
'email_domain' => null,
'login_label' => 'email',
],
"Yahoo" => [
'imap_host' => 'imap.mail.yahoo.com',
'imap_port' => 993,
'imap_tls' => 'imaps',
'smtp_host' => 'smtp.mail.yahoo.com',
'smtp_port' => 587,
'smtp_tls' => 'starttls',
'email_domain' => null,
'login_label' => 'email',
],
"iCloud" => [
'imap_host' => 'imap.mail.me.com',
'imap_port' => 993,
'imap_tls' => 'imaps',
'smtp_host' => 'smtp.mail.me.com',
'smtp_port' => 587,
'smtp_tls' => 'starttls',
'email_domain' => null,
'login_label' => 'email',
],
"AOL" => [
'imap_host' => 'imap.aol.com',
'imap_port' => 993,
'imap_tls' => 'imaps',
'smtp_host' => 'smtp.aol.com',
'smtp_port' => 587,
'smtp_tls' => 'starttls',
'email_domain' => null,
'login_label' => 'email',
],
"Fastmail" => [
'imap_host' => 'imap.fastmail.com',
'imap_port' => 993,
'imap_tls' => 'imaps',
'smtp_host' => 'smtp.fastmail.com',
'smtp_port' => 587,
'smtp_tls' => 'starttls',
'email_domain' => null,
'login_label' => 'email',
],
];
/**
* Returns the list of accounts the given user has registered, in
* insertion order. Password ciphertext is NOT decrypted; callers
* who need to make IMAP connections must use
* getAccountWithPassword() for the specific account they intend
* to talk to.
*
* @param int $user_id Yioop USER_ID whose accounts to list
* @return array list of row arrays with keys ID, DISPLAY_NAME,
* PROVIDER, HOST, PORT, USERNAME, TLS_MODE, DEFAULT_FOLDER,
* ALLOW_SELF_SIGNED, SMTP_HOST, SMTP_PORT, SMTP_USERNAME,
* SMTP_TLS_MODE, CREATED_AT, UPDATED_AT (no password
* columns)
*/
public function getAccountsForUser($user_id)
{
$db = $this->db;
$sql = "SELECT ID, USER_ID, DISPLAY_NAME, PROVIDER, HOST, PORT, " .
"USERNAME, TLS_MODE, DEFAULT_FOLDER, ALLOW_SELF_SIGNED, " .
"SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_TLS_MODE, " .
"SORT_ORDER, CREATED_AT, UPDATED_AT FROM MAIL_ACCOUNT " .
"WHERE USER_ID = ? " .
"ORDER BY SORT_ORDER ASC, CREATED_AT ASC, ID ASC";
$result = $db->execute($sql, [$user_id]);
$accounts = [];
if ($result) {
while ($row = $db->fetchArray($result)) {
$accounts[] = $row;
}
}
return $accounts;
}
/**
* Persists a user's chosen ordering of their mail accounts.
* Each account id is written a SORT_ORDER equal to its position
* in the supplied list (0-based), so a later getAccountsForUser
* returns them in this order. Ids are scoped to the owning user
* in the WHERE clause, so a forged id belonging to another user
* updates nothing. Ids not belonging to the user, or not present
* in the list, are left untouched.
*
* @param int $user_id owner of the accounts being reordered
* @param array $ordered_ids account ids in the desired display
* order
*/
public function updateAccountOrder($user_id, $ordered_ids)
{
$db = $this->db;
$now = time();
$position = 0;
$sql = "UPDATE MAIL_ACCOUNT SET SORT_ORDER = ?, " .
"UPDATED_AT = ? WHERE ID = ? AND USER_ID = ?";
foreach ($ordered_ids as $account_id) {
$db->execute($sql, [$position, $now,
(int) $account_id, $user_id]);
$position++;
}
}
/**
* Derives the sender email address from an account row,
* applying the same precedence the compose UI uses for the
* From: header. Lives on the model rather than a controller
* helper so the MailScheduledDispatcher (a library class)
* can use it without reaching into a controller component.
*
* Order:
* 1. USERNAME if it already contains '@'
* 2. SMTP_USERNAME if it contains '@'
* 3. USERNAME + '@' + provider preset email_domain
* (e.g. Gmail's gmail.com), when both are present
* 4. USERNAME + '@' + HOST, as a last-resort fallback for
* self-hosted accounts where USERNAME is just a local
* part and the IMAP host happens to match the email
* domain
*
* @param array $account row with USERNAME, SMTP_USERNAME,
* PROVIDER, HOST keys (subset is fine)
* @return string the sender email address, or '' when no
* usable username is present
*/
public static function senderEmail($account)
{
$username = trim($account['USERNAME'] ?? '');
if (strpos($username, '@') !== false) {
return $username;
}
$smtp_username = trim($account['SMTP_USERNAME'] ?? '');
if (strpos($smtp_username, '@') !== false) {
return $smtp_username;
}
$provider = trim($account['PROVIDER'] ?? '');
if ($provider !== '' && isset(
self::PROVIDER_PRESETS[$provider]
['email_domain']) &&
self::PROVIDER_PRESETS[$provider]
['email_domain'] !== '' && $username !== '') {
return $username . '@' .
self::PROVIDER_PRESETS[$provider]['email_domain'];
}
$host = trim($account['HOST'] ?? '');
return $username === '' ? '' :
($username . '@' . $host);
}
/**
* Returns one account row by id, scoped to the supplied user so
* a hand-crafted URL with another user's account id will get a
* false return. Password ciphertext is not surfaced; instead two
* derived boolean keys, HAS_PASSWORD and HAS_SMTP_PASSWORD,
* report whether a credential is stored for each section. The
* edit form uses these to decide whether to show the
* masked-sentinel placeholder in the password fields.
*
* @param int $account_id MAIL_ACCOUNT.ID to fetch
* @param int $user_id USER_ID the account must belong to
* @return array|false the row, or false when no matching account
* exists for that user
*/
public function getAccount($account_id, $user_id)
{
$db = $this->db;
$sql = "SELECT ID, USER_ID, DISPLAY_NAME, PROVIDER, HOST, PORT, " .
"USERNAME, TLS_MODE, DEFAULT_FOLDER, ALLOW_SELF_SIGNED, " .
"SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_TLS_MODE, " .
"CREATED_AT, UPDATED_AT, " .
"CASE WHEN LENGTH(PASSWORD_CIPHERTEXT) > 0 THEN 1 ELSE 0 END " .
"AS HAS_PASSWORD, " .
"CASE WHEN LENGTH(SMTP_PASSWORD_CIPHERTEXT) > 0 THEN 1 " .
"ELSE 0 END AS HAS_SMTP_PASSWORD " .
"FROM MAIL_ACCOUNT WHERE ID = ? AND USER_ID = ? " .
$db->limitOffset(1);
$result = $db->execute($sql, [$account_id, $user_id]);
if (!$result) {
return false;
}
$row = $db->fetchArray($result);
return $row ?: false;
}
/**
* Same as getAccount() but additionally decrypts the IMAP
* password and places it under the 'PASSWORD' key in the
* returned row. Throws \Exception if the stored ciphertext fails
* authentication, which indicates either tampering or master-key
* rotation that lost the old key. When no IMAP password was ever
* stored the result's PASSWORD key is the empty string.
*
* @param int $account_id MAIL_ACCOUNT.ID to fetch
* @param int $user_id USER_ID the account must belong to
* @return array|false the row with PASSWORD populated, or false
* when no matching account exists
*/
public function getAccountWithPassword($account_id, $user_id)
{
$db = $this->db;
$sql = "SELECT * FROM MAIL_ACCOUNT " .
"WHERE ID = ? AND USER_ID = ? " . $db->limitOffset(1);
$result = $db->execute($sql, [$account_id, $user_id]);
if (!$result) {
return false;
}
$row = $db->fetchArray($result);
if (!$row) {
return false;
}
if (!empty($row['PASSWORD_NONCE'])) {
$row['PASSWORD'] = CredentialCipher::decrypt($this->masterKey(),
$row['PASSWORD_NONCE'], $row['PASSWORD_CIPHERTEXT']);
} else {
$row['PASSWORD'] = '';
}
unset($row['PASSWORD_NONCE'], $row['PASSWORD_CIPHERTEXT'],
$row['SMTP_PASSWORD_NONCE'], $row['SMTP_PASSWORD_CIPHERTEXT']);
return $row;
}
/**
* Returns every mail account a user owns, each with its login
* password decrypted and ready for a live IMAP connection. This
* is the list the unread-badge probe walks: it needs each
* account's password to log in and count unseen messages, so
* this pairs the account list with a per-account credential
* lookup in one call and skips any row whose credentials cannot
* be loaded.
*
* @param int $user_id the user whose accounts to return
* @return array list of account rows, each including a decrypted
* PASSWORD field; empty when the user has no mail accounts
*/
public function getAccountsWithPassword($user_id)
{
$accounts = [];
foreach ($this->getAccountsForUser($user_id) as $row) {
$account = $this->getAccountWithPassword($row['ID'],
$user_id);
if ($account) {
$accounts[] = $account;
}
}
return $accounts;
}
/**
* Same as getAccount() but additionally decrypts the SMTP
* password and places it under the 'SMTP_PASSWORD' key in the
* returned row. Used by the outbound-mail path. Throws
* \Exception if the stored ciphertext fails authentication. When
* no SMTP password was ever stored the decrypt yields the empty
* string.
*
* @param int $account_id MAIL_ACCOUNT.ID to fetch
* @param int $user_id USER_ID the account must belong to
* @return array|false the row with SMTP_PASSWORD populated, or
* false when no matching account exists
*/
public function getAccountWithSmtpPassword($account_id, $user_id)
{
$db = $this->db;
$sql = "SELECT * FROM MAIL_ACCOUNT " .
"WHERE ID = ? AND USER_ID = ? " . $db->limitOffset(1);
$result = $db->execute($sql, [$account_id, $user_id]);
if (!$result) {
return false;
}
$row = $db->fetchArray($result);
if (!$row) {
return false;
}
if (!empty($row['SMTP_PASSWORD_NONCE'])) {
$row['SMTP_PASSWORD'] = CredentialCipher::decrypt(
$this->masterKey(), $row['SMTP_PASSWORD_NONCE'],
$row['SMTP_PASSWORD_CIPHERTEXT']);
} else {
$row['SMTP_PASSWORD'] = '';
}
unset($row['PASSWORD_NONCE'], $row['PASSWORD_CIPHERTEXT'],
$row['SMTP_PASSWORD_NONCE'], $row['SMTP_PASSWORD_CIPHERTEXT']);
return $row;
}
/**
* Returns the account row with both IMAP and SMTP credentials
* decrypted, applying the SMTP-falls-back-to-IMAP rule for
* username and password. When the stored SMTP_USERNAME is the
* empty string the IMAP USERNAME is used in its place, and
* likewise for the password. Callers wiring up an SMTP
* transmission can read SMTP_USERNAME and SMTP_PASSWORD
* directly without having to know about the dual-credential
* convention.
*
* @param int $account_id MAIL_ACCOUNT.ID to fetch
* @param int $user_id USER_ID the account must belong to
* @return array|false the row with USERNAME, PASSWORD,
* SMTP_USERNAME, SMTP_PASSWORD populated (SMTP fields
* defaulting to their IMAP counterparts when blank), or
* false when no matching account exists
*/
public function getAccountForSending($account_id, $user_id)
{
$db = $this->db;
$sql = "SELECT * FROM MAIL_ACCOUNT " .
"WHERE ID = ? AND USER_ID = ? " . $db->limitOffset(1);
$result = $db->execute($sql, [$account_id, $user_id]);
if (!$result) {
return false;
}
$row = $db->fetchArray($result);
if (!$row) {
return false;
}
if (!empty($row['PASSWORD_NONCE'])) {
$row['PASSWORD'] = CredentialCipher::decrypt(
$this->masterKey(), $row['PASSWORD_NONCE'],
$row['PASSWORD_CIPHERTEXT']);
} else {
$row['PASSWORD'] = '';
}
if (!empty($row['SMTP_PASSWORD_NONCE'])) {
$row['SMTP_PASSWORD'] = CredentialCipher::decrypt(
$this->masterKey(), $row['SMTP_PASSWORD_NONCE'],
$row['SMTP_PASSWORD_CIPHERTEXT']);
} else {
$row['SMTP_PASSWORD'] = '';
}
/* SMTP_USERNAME and SMTP_PASSWORD default to the IMAP
USERNAME / PASSWORD when blank. This is the common
case: most providers accept the same credentials on
both ports, and the Add-Account UI lets a user fill in
SMTP fields only if they actually differ. */
if (($row['SMTP_USERNAME'] ?? '') === '') {
$row['SMTP_USERNAME'] = $row['USERNAME'];
}
if ($row['SMTP_PASSWORD'] === '') {
$row['SMTP_PASSWORD'] = $row['PASSWORD'];
}
unset($row['PASSWORD_NONCE'], $row['PASSWORD_CIPHERTEXT'],
$row['SMTP_PASSWORD_NONCE'], $row['SMTP_PASSWORD_CIPHERTEXT']);
return $row;
}
/**
* Encrypts a credential for storage, with one special case: an
* empty-string plaintext is stored as an empty nonce and empty
* ciphertext rather than being run through the cipher. Encrypting
* the empty string still produces non-empty ciphertext (the
* authentication tag), which would make a "is a password stored"
* length check meaningless; storing genuinely empty columns keeps
* the HAS_PASSWORD / HAS_SMTP_PASSWORD flags from getAccount()
* honest. The test is strict (=== '') so a real password of "0"
* is still encrypted normally.
*
* @param string $plaintext the credential to store, or '' for
* "no credential"
* @return array ['nonce' => base64 string, 'ciphertext' =>
* base64 string]; both empty strings when $plaintext is ''
*/
protected function encryptCredential(string $plaintext)
{
if ($plaintext === '') {
return ['nonce' => '', 'ciphertext' => ''];
}
return CredentialCipher::encrypt($this->masterKey(), $plaintext);
}
/**
* Loads this installation's master encryption key, generating and
* storing a fresh one the first time it is needed. The key lives in
* the private database (the MAIL_SECRET table) so that a dump of
* the public database alone cannot decrypt stored passwords. The
* raw key is cached on the model for the rest of the request so
* repeated encrypt/decrypt calls do not re-query.
*
* @return string the raw 32-byte master key
*/
protected function masterKey()
{
if ($this->master_key !== null) {
return $this->master_key;
}
$db = $this->private_db;
$sql = "SELECT KEY_VALUE FROM MAIL_SECRET WHERE KEY_ID = 1";
$result = $db->execute($sql);
$row = $result ? $db->fetchArray($result) : false;
if ($row && !empty($row['KEY_VALUE'])) {
$key = base64_decode($row['KEY_VALUE'], true);
if ($key === false ||
strlen($key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
throw new \Exception(
"MailAccountModel: stored master key is corrupt");
}
$this->master_key = $key;
return $key;
}
$key = random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
$insert = "INSERT INTO MAIL_SECRET (KEY_ID, KEY_VALUE) " .
"VALUES (1, ?)";
$db->execute($insert, [base64_encode($key)]);
$this->master_key = $key;
return $key;
}
/**
* Inserts a new account row for the user. A non-empty IMAP or
* SMTP password is encrypted before being persisted; the
* plaintext is never stored, and an empty password is stored as
* empty columns rather than encrypted. Returns the new account
* id, or false on failure.
*
* @param int $user_id USER_ID the account belongs to
* @param array $fields associative array with keys DISPLAY_NAME,
* PROVIDER, HOST, PORT, USERNAME, PASSWORD, TLS_MODE,
* DEFAULT_FOLDER, ALLOW_SELF_SIGNED, SMTP_HOST, SMTP_PORT,
* SMTP_USERNAME, SMTP_PASSWORD, SMTP_TLS_MODE
* @return int|false the new MAIL_ACCOUNT.ID, or false
*/
public function addAccount($user_id, $fields)
{
$encrypted = $this->encryptCredential($fields['PASSWORD'] ?? '');
$smtp_encrypted = $this->encryptCredential(
$fields['SMTP_PASSWORD'] ?? '');
$now = time();
$sql = "INSERT INTO MAIL_ACCOUNT " .
"(USER_ID, DISPLAY_NAME, PROVIDER, HOST, PORT, USERNAME, " .
"PASSWORD_NONCE, PASSWORD_CIPHERTEXT, TLS_MODE, " .
"DEFAULT_FOLDER, ALLOW_SELF_SIGNED, SMTP_HOST, SMTP_PORT, " .
"SMTP_USERNAME, SMTP_PASSWORD_NONCE, " .
"SMTP_PASSWORD_CIPHERTEXT, SMTP_TLS_MODE, " .
"CREATED_AT, UPDATED_AT) " .
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, " .
"?, ?)";
$params = [
$user_id,
$fields['DISPLAY_NAME'] ?? '',
$fields['PROVIDER'] ?? '',
$fields['HOST'] ?? '',
intval($fields['PORT'] ?? 993),
$fields['USERNAME'] ?? '',
$encrypted['nonce'],
$encrypted['ciphertext'],
$fields['TLS_MODE'] ?? 'imaps',
$fields['DEFAULT_FOLDER'] ?? 'INBOX',
empty($fields['ALLOW_SELF_SIGNED']) ? 0 : 1,
$fields['SMTP_HOST'] ?? '',
intval($fields['SMTP_PORT'] ?? 587),
$fields['SMTP_USERNAME'] ?? '',
$smtp_encrypted['nonce'],
$smtp_encrypted['ciphertext'],
$fields['SMTP_TLS_MODE'] ?? 'starttls',
$now,
$now,
];
$result = $this->db->execute($sql, $params);
if (!$result) {
return false;
}
return $this->db->insertID('MAIL_ACCOUNT');
}
/**
* Updates an account row. When $leave_password is true (the
* typical case where the user submitted an Edit form with the
* IMAP password field empty), the stored IMAP ciphertext is
* preserved unchanged; likewise $leave_smtp_password for the
* SMTP password. Otherwise the corresponding new password is
* encrypted and persisted.
*
* @param int $account_id MAIL_ACCOUNT.ID to update
* @param int $user_id USER_ID; updates only succeed when the
* account belongs to this user
* @param array $fields associative array with the same keys as
* addAccount(); PASSWORD is honored only when
* $leave_password is false, SMTP_PASSWORD only when
* $leave_smtp_password is false
* @param bool $leave_password when true, the existing stored
* IMAP ciphertext is kept and $fields['PASSWORD'] is ignored
* @param bool $leave_smtp_password when true, the existing
* stored SMTP ciphertext is kept and $fields['SMTP_PASSWORD']
* is ignored
* @return bool whether the row was updated
*/
public function updateAccount($account_id, $user_id, $fields,
$leave_password = true, $leave_smtp_password = true)
{
$existing = $this->getAccount($account_id, $user_id);
if (!$existing) {
return false;
}
$now = time();
$set = "DISPLAY_NAME = ?, PROVIDER = ?, HOST = ?, PORT = ?, " .
"USERNAME = ?, TLS_MODE = ?, DEFAULT_FOLDER = ?, " .
"ALLOW_SELF_SIGNED = ?, SMTP_HOST = ?, SMTP_PORT = ?, " .
"SMTP_USERNAME = ?, SMTP_TLS_MODE = ?, UPDATED_AT = ?";
$params = [
$fields['DISPLAY_NAME'] ?? $existing['DISPLAY_NAME'],
$fields['PROVIDER'] ?? ($existing['PROVIDER'] ?? ''),
$fields['HOST'] ?? $existing['HOST'],
intval($fields['PORT'] ?? $existing['PORT']),
$fields['USERNAME'] ?? $existing['USERNAME'],
$fields['TLS_MODE'] ?? $existing['TLS_MODE'],
$fields['DEFAULT_FOLDER'] ?? $existing['DEFAULT_FOLDER'],
empty($fields['ALLOW_SELF_SIGNED']) ? 0 : 1,
$fields['SMTP_HOST'] ?? $existing['SMTP_HOST'],
intval($fields['SMTP_PORT'] ?? $existing['SMTP_PORT']),
$fields['SMTP_USERNAME'] ?? $existing['SMTP_USERNAME'],
$fields['SMTP_TLS_MODE'] ?? $existing['SMTP_TLS_MODE'],
$now,
];
if (!$leave_password) {
$encrypted = $this->encryptCredential(
$fields['PASSWORD'] ?? '');
$set .= ", PASSWORD_NONCE = ?, PASSWORD_CIPHERTEXT = ?";
$params[] = $encrypted['nonce'];
$params[] = $encrypted['ciphertext'];
}
if (!$leave_smtp_password) {
$smtp_encrypted = $this->encryptCredential(
$fields['SMTP_PASSWORD'] ?? '');
$set .= ", SMTP_PASSWORD_NONCE = ?, " .
"SMTP_PASSWORD_CIPHERTEXT = ?";
$params[] = $smtp_encrypted['nonce'];
$params[] = $smtp_encrypted['ciphertext'];
}
$sql = "UPDATE MAIL_ACCOUNT SET $set WHERE ID = ? AND USER_ID = ?";
$params[] = $account_id;
$params[] = $user_id;
return (bool) $this->db->execute($sql, $params);
}
/**
* Updates only the DISPLAY_NAME column of a mail account.
* Used by inline rename (long-press the account header in
* the side panel). Scoped to user_id so a forged request
* with another user's account_id is a no-op.
*
* @param int $account_id MAIL_ACCOUNT.ID to update
* @param int $user_id USER_ID the account must belong to
* @param string $display_name the new display name
* @return bool whether the update statement succeeded
*/
public function updateDisplayName($account_id, $user_id,
$display_name)
{
$sql = "UPDATE MAIL_ACCOUNT SET DISPLAY_NAME = ?, " .
"UPDATED_AT = ? WHERE ID = ? AND USER_ID = ?";
return (bool) $this->db->execute($sql,
[$display_name, time(), $account_id, $user_id]);
}
/**
* Removes an account row. Scoped to the supplied user; a delete
* with an account_id that does not belong to that user is a
* no-op (returns true to keep the controller path simple, but
* affects zero rows).
*
* @param int $account_id MAIL_ACCOUNT.ID to delete
* @param int $user_id USER_ID the account must belong to
* @return bool whether the delete statement succeeded
*/
public function deleteAccount($account_id, $user_id)
{
$sql = "DELETE FROM MAIL_ACCOUNT " .
"WHERE ID = ? AND USER_ID = ?";
return (bool) $this->db->execute($sql,
[$account_id, $user_id]);
}
}