Add background mail checking and new mail notifications
This commit is contained in:
269
lib/IdentSwitchChecker.php
Normal file
269
lib/IdentSwitchChecker.php
Normal file
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
/**
|
||||
* ident_switch - Background mail checker.
|
||||
*
|
||||
* Checks new mail across secondary identities during the refresh
|
||||
* cycle and sends unread counts + notifications to the client.
|
||||
* When impersonating a secondary account, also checks the primary account.
|
||||
*
|
||||
* Copyright (C) 2026 Gecka
|
||||
*
|
||||
* Licensed under AGPL-3.0+.
|
||||
*
|
||||
* @url https://github.com/Gecka-apps/ident_switch
|
||||
*/
|
||||
class IdentSwitchChecker
|
||||
{
|
||||
/**
|
||||
* Called on each refresh cycle.
|
||||
*
|
||||
* Builds the list of identities to check (excluding the currently active one,
|
||||
* including the primary account when impersonating), then checks them
|
||||
* in round-robin or all-at-once mode.
|
||||
*
|
||||
* @param array $args Hook arguments (empty for refresh hook).
|
||||
* @return array Unmodified hook arguments.
|
||||
*/
|
||||
public function check_new_mail(array $args): array
|
||||
{
|
||||
$rc = rcmail::get_instance();
|
||||
|
||||
$identities = $this->get_checkable_identities($rc);
|
||||
|
||||
// Exclude the currently active secondary identity (RC already checks it)
|
||||
$activeIid = $_SESSION['iid' . ident_switch::MY_POSTFIX] ?? -1;
|
||||
$identities = array_values(array_filter($identities, function ($id) use ($activeIid) {
|
||||
return $id['iid'] != $activeIid;
|
||||
}));
|
||||
|
||||
// When impersonating, also check the primary account
|
||||
$isImpersonating = strcasecmp($rc->user->data['username'], $_SESSION['username']) !== 0;
|
||||
if ($isImpersonating) {
|
||||
$primary = $this->get_primary_identity($rc);
|
||||
if ($primary) {
|
||||
$identities[] = $primary;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($identities)) {
|
||||
$this->send_counts($rc);
|
||||
return $args;
|
||||
}
|
||||
|
||||
if ($rc->config->get('ident_switch.round_robin', false)) {
|
||||
// Round-robin: check one identity per refresh cycle
|
||||
$index = ($_SESSION['ident_switch_check_index'] ?? -1) + 1;
|
||||
if ($index >= count($identities)) {
|
||||
$index = 0;
|
||||
}
|
||||
$_SESSION['ident_switch_check_index'] = $index;
|
||||
$this->check_identity($rc, $identities[$index]);
|
||||
} else {
|
||||
foreach ($identities as $identity) {
|
||||
$this->check_identity($rc, $identity);
|
||||
}
|
||||
}
|
||||
|
||||
$this->send_counts($rc);
|
||||
|
||||
return $args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a single identity for unseen messages and notify if new mail.
|
||||
*
|
||||
* @param rcmail $rc Roundcube instance.
|
||||
* @param array $identity Identity record from the database.
|
||||
*/
|
||||
private function check_identity(rcmail $rc, array $identity): void
|
||||
{
|
||||
$counts = $_SESSION['ident_switch_counts'] ?? [];
|
||||
$previousCount = $counts[$identity['iid']]['unseen'] ?? 0;
|
||||
|
||||
$count = $this->check_unseen($rc, $identity, $previousCount);
|
||||
|
||||
ident_switch::write_log("Check identity {$identity['iid']} ({$identity['email']}): unseen={$count}, previous={$previousCount}");
|
||||
|
||||
// Set baseline on first check; preserve it across subsequent checks
|
||||
$baseline = $counts[$identity['iid']]['baseline'] ?? $count;
|
||||
|
||||
$counts[$identity['iid']] = [
|
||||
'unseen' => $count,
|
||||
'baseline' => $baseline,
|
||||
'checked_at' => time(),
|
||||
];
|
||||
$_SESSION['ident_switch_counts'] = $counts;
|
||||
|
||||
if ($count > $previousCount) {
|
||||
$this->send_notification($rc, $identity, $count);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to an identity's IMAP server and return INBOX unseen count.
|
||||
*
|
||||
* @param rcmail $rc Roundcube instance.
|
||||
* @param array $identity Identity DB record.
|
||||
* @param int $previousCount Previous unseen count (returned on error).
|
||||
* @return int Unseen message count.
|
||||
*/
|
||||
private function check_unseen(rcmail $rc, array $identity, int $previousCount): int
|
||||
{
|
||||
$imap = new rcube_imap_generic();
|
||||
|
||||
$host = $identity['imap_host'] ?: 'localhost';
|
||||
$port = $identity['imap_port'] ?: 143;
|
||||
$ssl = false;
|
||||
|
||||
if (!empty($identity['flags']) && ($identity['flags'] & ident_switch::DB_SECURE_IMAP_TLS)) {
|
||||
$ssl = 'tls';
|
||||
}
|
||||
|
||||
// Strip and parse protocol prefix from host
|
||||
$hostLower = strtolower($host);
|
||||
if (str_starts_with($hostLower, 'tls://')) {
|
||||
$ssl = 'tls';
|
||||
$host = substr($host, 6);
|
||||
} elseif (str_starts_with($hostLower, 'ssl://')) {
|
||||
$ssl = 'ssl';
|
||||
$host = substr($host, 6);
|
||||
if (!$identity['imap_port']) {
|
||||
$port = 993;
|
||||
}
|
||||
}
|
||||
|
||||
$username = $identity['username'] ?: $identity['email'];
|
||||
$password = $rc->decrypt($identity['password']);
|
||||
|
||||
$result = $imap->connect($host, $username, $password, [
|
||||
'port' => $port,
|
||||
'ssl_mode' => $ssl,
|
||||
'timeout' => 5,
|
||||
]);
|
||||
|
||||
if (!$result) {
|
||||
ident_switch::write_log("Failed to check mail for identity {$identity['iid']}: " . $imap->error);
|
||||
return $previousCount;
|
||||
}
|
||||
|
||||
$status = $imap->status('INBOX', ['UNSEEN']);
|
||||
$unseen = $status['UNSEEN'] ?? 0;
|
||||
|
||||
$imap->closeConnection();
|
||||
|
||||
return $unseen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a virtual identity record for the primary account.
|
||||
*
|
||||
* When the user has switched to a secondary account, the primary account's
|
||||
* connection details are saved in session with the MY_POSTFIX suffix.
|
||||
*
|
||||
* @param rcmail $rc Roundcube instance.
|
||||
* @return array|null Identity-like array, or null if session data is missing.
|
||||
*/
|
||||
private function get_primary_identity(rcmail $rc): ?array
|
||||
{
|
||||
$postfix = ident_switch::MY_POSTFIX;
|
||||
|
||||
if (!isset($_SESSION['password' . $postfix])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$host = $_SESSION['storage_host' . $postfix] ?? 'localhost';
|
||||
$port = $_SESSION['storage_port' . $postfix] ?? 143;
|
||||
$ssl = $_SESSION['storage_ssl' . $postfix] ?? null;
|
||||
|
||||
// Prepend protocol prefix so check_unseen() can parse it
|
||||
if ($ssl === 'ssl' && !str_starts_with(strtolower($host), 'ssl://')) {
|
||||
$host = 'ssl://' . $host;
|
||||
} elseif ($ssl === 'tls' && !str_starts_with(strtolower($host), 'tls://')) {
|
||||
$host = 'tls://' . $host;
|
||||
}
|
||||
|
||||
return [
|
||||
'iid' => 0,
|
||||
'imap_host' => $host,
|
||||
'imap_port' => $port,
|
||||
'flags' => 0,
|
||||
'username' => $rc->user->data['username'],
|
||||
'password' => $_SESSION['password' . $postfix],
|
||||
'email' => $rc->user->data['username'],
|
||||
'label' => $_SESSION['global_alias'] ?? $rc->user->data['username'],
|
||||
'notify_basic' => null,
|
||||
'notify_sound' => null,
|
||||
'notify_desktop' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all enabled identities that have mail checking enabled.
|
||||
*
|
||||
* @param rcmail $rc Roundcube instance.
|
||||
* @return array List of identity records.
|
||||
*/
|
||||
private function get_checkable_identities(rcmail $rc): array
|
||||
{
|
||||
$sql = 'SELECT isw.iid, isw.imap_host, isw.imap_port, isw.flags, '
|
||||
. 'isw.username, isw.password, isw.label, '
|
||||
. 'isw.notify_basic, isw.notify_sound, isw.notify_desktop, '
|
||||
. 'ii.email '
|
||||
. 'FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' isw '
|
||||
. 'INNER JOIN ' . $rc->db->table_name('identities') . ' ii ON isw.iid = ii.identity_id '
|
||||
. 'WHERE isw.user_id = ? AND isw.flags & ? > 0 AND isw.notify_check = ?';
|
||||
|
||||
$q = $rc->db->query($sql, $rc->user->ID, ident_switch::DB_ENABLED, ident_switch::NOTIFY_CHECK_ENABLED);
|
||||
|
||||
$identities = [];
|
||||
while ($r = $rc->db->fetch_assoc($q)) {
|
||||
$identities[] = $r;
|
||||
}
|
||||
|
||||
return $identities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send all cached unseen counts to client JS.
|
||||
*
|
||||
* @param rcmail $rc Roundcube instance.
|
||||
*/
|
||||
private function send_counts(rcmail $rc): void
|
||||
{
|
||||
$counts = $_SESSION['ident_switch_counts'] ?? [];
|
||||
$data = [];
|
||||
foreach ($counts as $iid => $info) {
|
||||
$data[$iid] = [
|
||||
'unseen' => $info['unseen'],
|
||||
'baseline' => $info['baseline'] ?? $info['unseen'],
|
||||
];
|
||||
}
|
||||
|
||||
$rc->output->command('plugin.ident_switch.update_counts', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification command to client for a specific identity.
|
||||
*
|
||||
* @param rcmail $rc Roundcube instance.
|
||||
* @param array $identity Identity record.
|
||||
* @param int $count New unseen count.
|
||||
*/
|
||||
private function send_notification(rcmail $rc, array $identity, int $count): void
|
||||
{
|
||||
$basic = $identity['notify_basic'] ?? $rc->config->get('newmail_notifier_basic', false);
|
||||
$sound = $identity['notify_sound'] ?? $rc->config->get('newmail_notifier_sound', false);
|
||||
$desktop = $identity['notify_desktop'] ?? $rc->config->get('newmail_notifier_desktop', false);
|
||||
|
||||
$label = $identity['label'] ?: $identity['email'];
|
||||
|
||||
$rc->output->command('plugin.ident_switch.notify', [
|
||||
'iid' => $identity['iid'],
|
||||
'label' => $label,
|
||||
'count' => $count,
|
||||
'basic' => (bool)$basic,
|
||||
'sound' => (bool)$sound,
|
||||
'desktop' => (bool)$desktop,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,48 @@ class IdentSwitchForm
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the notification form fields for identity settings.
|
||||
*
|
||||
* Shows the actual newmail_notifier default value in each tri-state select.
|
||||
* Selecting the default option stores NULL so changes to global defaults propagate.
|
||||
*
|
||||
* @param array $record Identity record data used for default values.
|
||||
* @return array Form field definitions for notification preferences.
|
||||
*/
|
||||
public function get_notification_fields(array &$record): array
|
||||
{
|
||||
$rc = rcmail::get_instance();
|
||||
$prefix = 'ident_switch.form.notify.';
|
||||
|
||||
$defaultKeys = [
|
||||
'basic' => 'newmail_notifier_basic',
|
||||
'sound' => 'newmail_notifier_sound',
|
||||
'desktop' => 'newmail_notifier_desktop',
|
||||
];
|
||||
|
||||
$triState = function (string $name) use ($prefix, &$record, $rc, $defaultKeys): string {
|
||||
$defaultVal = $rc->config->get($defaultKeys[$name] ?? '', false);
|
||||
$defaultLabel = $defaultVal
|
||||
? $this->plugin->gettext('form.notify.on')
|
||||
: $this->plugin->gettext('form.notify.off');
|
||||
$defaultLabel .= ' (' . strtolower($this->plugin->gettext('form.notify.default')) . ')';
|
||||
|
||||
$select = new html_select(['name' => "_{$prefix}{$name}"]);
|
||||
$select->add($defaultLabel, '');
|
||||
$select->add($this->plugin->gettext('form.notify.on'), '1');
|
||||
$select->add($this->plugin->gettext('form.notify.off'), '0');
|
||||
return $select->show($record[$prefix . $name] ?? '');
|
||||
};
|
||||
|
||||
return [
|
||||
$prefix . 'check' => ['type' => 'checkbox'],
|
||||
$prefix . 'basic' => ['value' => $triState('basic')],
|
||||
$prefix . 'sound' => ['value' => $triState('sound')],
|
||||
$prefix . 'desktop' => ['value' => $triState('desktop')],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle identity_form hook: add plugin-specific fields to the identity editor.
|
||||
*
|
||||
@@ -150,7 +192,11 @@ class IdentSwitchForm
|
||||
'sieve_host' => 'sieve.host',
|
||||
'sieve_port' => 'sieve.port',
|
||||
'sieve_auth' => 'sieve.auth',
|
||||
];
|
||||
'notify_check' => 'notify.check',
|
||||
'notify_basic' => 'notify.basic',
|
||||
'notify_sound' => 'notify.sound',
|
||||
'notify_desktop' => 'notify.desktop',
|
||||
];
|
||||
foreach ($row as $k => $v) {
|
||||
if (isset($dbToForm[$k])) {
|
||||
$record['ident_switch.form.' . $dbToForm[$k]] = $v;
|
||||
@@ -189,6 +235,22 @@ class IdentSwitchForm
|
||||
'name' => $this->plugin->gettext('form.sieve.caption'),
|
||||
'content' => $this->get_sieve_fields($record),
|
||||
];
|
||||
if (!$rc->config->get('ident_switch.check_mail', true)) {
|
||||
// Admin disabled background mail checking
|
||||
} elseif ($rc->plugins->get_plugin('newmail_notifier')) {
|
||||
$args['form']['ident_switch.notify'] = [
|
||||
'name' => $this->plugin->gettext('form.notify.caption'),
|
||||
'content' => $this->get_notification_fields($record),
|
||||
];
|
||||
} elseif (!$rc->config->get('ident_switch.hide_notifier_warning', false)) {
|
||||
$args['form']['ident_switch.notify'] = [
|
||||
'name' => $this->plugin->gettext('form.notify.caption'),
|
||||
'content' => html::div(
|
||||
['class' => 'boxinformation'],
|
||||
rcube::Q($this->plugin->gettext('form.notify.requires_newmail_notifier'))
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return $args;
|
||||
}
|
||||
@@ -449,6 +511,18 @@ class IdentSwitchForm
|
||||
return $retVal;
|
||||
}
|
||||
|
||||
// Notification settings
|
||||
$retVal['notify.check'] = self::get_field_value('notify', 'check', false) ? 1 : 0;
|
||||
|
||||
$notifyBasic = self::get_field_value('notify', 'basic');
|
||||
$retVal['notify.basic'] = ($notifyBasic !== null && $notifyBasic !== '') ? (int)$notifyBasic : null;
|
||||
|
||||
$notifySound = self::get_field_value('notify', 'sound');
|
||||
$retVal['notify.sound'] = ($notifySound !== null && $notifySound !== '') ? (int)$notifySound : null;
|
||||
|
||||
$notifyDesktop = self::get_field_value('notify', 'desktop');
|
||||
$retVal['notify.desktop'] = ($notifyDesktop !== null && $notifyDesktop !== '') ? (int)$notifyDesktop : null;
|
||||
|
||||
// Get also password
|
||||
$retVal['imap.pass'] = self::get_field_value('imap', 'password', false, true);
|
||||
|
||||
@@ -505,13 +579,19 @@ class IdentSwitchForm
|
||||
// Record already exists, will update it
|
||||
$sql = 'UPDATE ' .
|
||||
$rc->db->table_name(ident_switch::TABLE) .
|
||||
' SET flags = ?, label = ?, imap_host = ?, imap_port = ?, imap_delimiter = ?, username = ?, password = ?, smtp_host = ?, smtp_port = ?, smtp_auth = ?, sieve_host = ?, sieve_port = ?, sieve_auth = ?, user_id = ?, iid = ?' .
|
||||
' SET flags = ?, label = ?, imap_host = ?, imap_port = ?, imap_delimiter = ?, username = ?, password = ?,' .
|
||||
' smtp_host = ?, smtp_port = ?, smtp_auth = ?, sieve_host = ?, sieve_port = ?, sieve_auth = ?,' .
|
||||
' notify_check = ?, notify_basic = ?, notify_sound = ?, notify_desktop = ?,' .
|
||||
' user_id = ?, iid = ?' .
|
||||
' WHERE id = ?';
|
||||
} elseif ($data['flags'] & ident_switch::DB_ENABLED) {
|
||||
// No record exists, create new one
|
||||
$sql = 'INSERT INTO ' .
|
||||
$rc->db->table_name(ident_switch::TABLE) .
|
||||
'(flags, label, imap_host, imap_port, imap_delimiter, username, password, smtp_host, smtp_port, smtp_auth, sieve_host, sieve_port, sieve_auth, user_id, iid) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
|
||||
'(flags, label, imap_host, imap_port, imap_delimiter, username, password,' .
|
||||
' smtp_host, smtp_port, smtp_auth, sieve_host, sieve_port, sieve_auth,' .
|
||||
' notify_check, notify_basic, notify_sound, notify_desktop,' .
|
||||
' user_id, iid) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
@@ -536,6 +616,10 @@ class IdentSwitchForm
|
||||
$data['sieve.host'],
|
||||
$data['sieve.port'],
|
||||
$data['sieve.auth'],
|
||||
$data['notify.check'] ?? 1,
|
||||
$data['notify.basic'] ?? null,
|
||||
$data['notify.sound'] ?? null,
|
||||
$data['notify.desktop'] ?? null,
|
||||
$rc->user->ID,
|
||||
$data['id'],
|
||||
$r['id'] ?? null
|
||||
|
||||
@@ -139,6 +139,17 @@ class IdentSwitchPreconfig
|
||||
$record['ident_switch.form.common.readonly'] = $loginSet ? 2 : 1;
|
||||
}
|
||||
|
||||
// Notification defaults from preconfig
|
||||
if (isset($cfg['notify_check'])) {
|
||||
$record['ident_switch.form.notify.check'] = $cfg['notify_check'] ? 1 : 0;
|
||||
}
|
||||
foreach (['notify_basic', 'notify_sound', 'notify_desktop'] as $key) {
|
||||
if (isset($cfg[$key])) {
|
||||
$formKey = 'ident_switch.form.notify.' . substr($key, 7);
|
||||
$record[$formKey] = $cfg[$key] === null ? '' : ($cfg[$key] ? '1' : '0');
|
||||
}
|
||||
}
|
||||
|
||||
return (bool)($cfg['readonly'] ?? false);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ class IdentSwitchSwitcher
|
||||
$rc->session->remove('folders');
|
||||
$rc->session->remove('unseen_count');
|
||||
|
||||
// Reset baseline for the target account so delta goes back to 0
|
||||
$this->reset_baseline($identId == -1 ? 0 : null, $rc, $identId);
|
||||
|
||||
if ($identId == -1) {
|
||||
// Switch to main account
|
||||
ident_switch::write_log('Switching mailbox back to default.');
|
||||
@@ -346,4 +349,34 @@ class IdentSwitchSwitcher
|
||||
}
|
||||
return $args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the baseline for a target account so delta display resets to 0.
|
||||
*
|
||||
* For primary account (identId=-1), iid is 0.
|
||||
* For secondary accounts, look up iid from the ident_switch table.
|
||||
*
|
||||
* @param int|null $iid Known iid (0 for primary), or null to look up.
|
||||
* @param rcmail $rc Roundcube instance.
|
||||
* @param mixed $identId The ident_switch.id value for secondary accounts.
|
||||
*/
|
||||
private function reset_baseline(?int $iid, rcmail $rc, mixed $identId): void
|
||||
{
|
||||
if ($iid === null) {
|
||||
// Look up iid from ident_switch table for secondary account
|
||||
$sql = 'SELECT iid FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE id = ? AND user_id = ?';
|
||||
$q = $rc->db->query($sql, $identId, $rc->user->ID);
|
||||
$r = $rc->db->fetch_assoc($q);
|
||||
if (!$r) {
|
||||
return;
|
||||
}
|
||||
$iid = (int)$r['iid'];
|
||||
}
|
||||
|
||||
$counts = $_SESSION['ident_switch_counts'] ?? [];
|
||||
if (isset($counts[$iid])) {
|
||||
unset($counts[$iid]['baseline']);
|
||||
$_SESSION['ident_switch_counts'] = $counts;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user