<?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\views\elements;
use seekquarry\yioop as B;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\mail as ML;
use seekquarry\yioop\library\HtmlSanitizer;
use seekquarry\yioop\library\processors\ImageProcessor;
use seekquarry\yioop\models\MailAccountModel;
/**
* Element used to draw the Mail activity. Mirrors the two-pane
* shape of the Messages activity: a user-accounts side column on
* the left lists registered IMAP accounts (each with its folders),
* and an account-messages content pane on the right shows the
* inbox listing, the body of the currently selected message, or
* an account add / edit form. The two panes are separated by a
* split-adjuster, matching the usermessages page convention.
*
* On mobile the side column and content pane are rendered
* conditionally (one or the other, never both) so the narrow
* viewport always shows the relevant content full-width.
*
* Which content the right pane shows is selected by
* $data['CONTENT_PANE']:
* - selectAccount empty pane with a "pick an account" prompt
* - addAccount new-account form
* - editAccount edit existing account form
* - inbox envelope listing for one account
* - message single-message body view
*
* @author Chris Pollett
*/
class MailElement extends Element
{
/**
* Top-level dispatcher. Renders the mail-container shell with
* the user-accounts side column and the account-messages
* content pane, picking which content to render in the pane
* based on $data['CONTENT_PANE']. On mobile, hides the side
* column once an account is selected and hides the content
* pane until one is.
*
* @param array $data fields prepared by SocialComponent::userMail
* (and its helpers); see userMailBaseData for the scaffold
*/
public function render($data)
{
$is_mobile = !empty($data['MOBILE']);
$pane = $data['CONTENT_PANE'] ?? 'selectAccount';
$has_selection = ($pane !== 'selectAccount');
/* Add Account, Edit Account, and the clone sub-views
(form and status banner) are full-width edit screens:
they replace the entire activity container just like
every other edit screen elsewhere in Yioop. Skipping
the mail-container / user-accounts shell here means
the form picks up the standard .current-activity
typography and width instead of competing for space
with the side-list. */
$is_full_width_edit = in_array($pane,
['addAccount', 'editAccount', 'editMailsite'], true);
?>
<script>
window.MAIL_TRIANGLE_EXPANDED = <?=
json_encode(tl(
'mail_element_folder_triangle_expanded')) ?>;
window.MAIL_TRIANGLE_COLLAPSED = <?=
json_encode(tl(
'mail_element_folder_triangle_collapsed')) ?>;
window.MAIL_LOADING = <?=
json_encode(tl('mail_element_loading')) ?>;
</script>
<?php
if ($is_full_width_edit) {
?>
<div class="current-activity mail-current-activity">
<?php $this->renderAccountMessages($data); ?>
</div>
<?php
return;
}
?>
<div class="mail-container">
<?php
$deselect_url = B\controllerUrl("user_mail", true) .
C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')] .
"&deselect=1";
if (!$is_mobile || !$has_selection) {
?><div class="user-accounts"
data-deselect-url="<?=
$deselect_url ?>">
<?php $this->renderUserAccounts($data); ?>
</div><?php
}
if (!$is_mobile) {
?><div class="split-adjuster" id="split-adjuster"></div><?php
}
if (!$is_mobile || $has_selection) {
?><div class="account-messages">
<?php $this->renderAccountMessages($data); ?>
</div><?php
}
if (!$is_mobile) {
?><div class="mail-container-h-resizer"
id="mail-container-h-resizer"></div><?php
}
?>
</div>
<?php
if (!$is_mobile) {
?><div class="mail-container-resizer"
id="mail-container-resizer"></div><?php
}
}
/**
* Builds a URL for the Mail activity with optional extra query
* parameters. Uses the /user_mail/ route prefix so requests
* land directly on GroupController without bouncing through
* AdminController's redirect machinery.
*
* @param array $data $data with CSRF token set
* @param array $extra extra key => value pairs to append
* @return string the assembled URL (already entity-encoded)
*/
protected static function mailUrl($data, $extra = [])
{
$token = C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')];
$base = htmlentities(B\controllerUrl("user_mail", true)) . $token;
foreach ($extra as $key => $value) {
$base .= "&" . urlencode($key) . "=" .
urlencode((string) $value);
}
return $base;
}
/**
* Renders the user-accounts side column: Add Account button at
* the top (when external accounts are enabled) plus one
* labelled group per registered account. Each group shows the
* account's default folder as a clickable link with inline
* edit / delete controls. The currently-active account gets
* the user-accounts-active modifier class.
*
* @param array $data fields including ACCOUNTS, EXTERNAL_ENABLED,
* ACCOUNT_ID (when an account is selected)
*/
protected function renderUserAccounts($data)
{
?>
<span id="mail-folder-ops-strings" class="none"
data-mail-element-save="<?= tl(
'mail_element_save') ?>"
data-mail-element-edit-link="<?= tl(
'mail_element_edit_link') ?>"
data-mail-element-delete-link="<?= tl(
'mail_element_delete_link') ?>"
data-mail-element-folder-new="<?= tl(
'mail_element_folder_new') ?>"
data-mail-element-folder-new-child="<?= tl(
'mail_element_folder_new_child') ?>"
data-mail-element-folder-new-placeholder="<?=
tl(
'mail_element_folder_new_placeholder') ?>"
data-mail-element-folder-rename="<?= tl(
'mail_element_folder_rename') ?>"
data-mail-element-folder-delete="<?= tl(
'mail_element_folder_delete') ?>"
data-mail-element-folder-delete-confirm="<?=
tl(
'mail_element_folder_delete_confirm', '%s') ?>"
data-mail-element-account-rename-label="<?=
tl(
'mail_element_account_rename_label') ?>"
></span>
<div class="socialcontrols-wrap">
<?php $this->view->element("socialcontrols")->render($data); ?>
</div>
<?php
$active_account = (int) ($data['ACCOUNT_ID'] ?? 0);
$accounts = $data['ACCOUNTS'] ?? [];
if (empty($accounts)) {
return;
}
$toggle_url = B\controllerUrl("user_mail", true) .
C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')] .
"&arg=toggleAccount";
$folders_url = B\controllerUrl("user_mail", true) .
C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')] .
"&arg=listFolders";
$folder_expand_url = B\controllerUrl("user_mail", true) .
C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')] .
"&arg=toggleFolderExpanded";
$reorder_url = B\controllerUrl("user_mail", true) .
C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')] .
"&arg=reorderAccounts";
?>
<div class="user-accounts-list"
data-reorder-url="<?= $reorder_url ?>"
data-reorder-label="<?= tl(
'mail_element_account_drag_label') ?>">
<?php
foreach ($accounts as $account) {
$aid = (int) $account['ID'];
$is_active = ($aid === $active_account);
$is_mailsite = !empty($account['IS_MAILSITE']);
$edit_url = $is_mailsite ?
$this->mailUrl($data, ['arg' => 'editMailsite']) :
$this->mailUrl($data,
['arg' => 'editAccount', 'account_id' => $aid]);
$delete_url = $is_mailsite ? '' :
$this->mailUrl($data,
['arg' => 'deleteAccount', 'account_id' => $aid]);
$folder = $account['DEFAULT_FOLDER'] ?: 'INBOX';
/* If a clone job is active and this row is its source
account, paint the whole group light-green and
disable the rename/delete handles so the user
cannot edit the account mid-clone. The account
name picks up a "(Cloning...)" link next to it that
jumps to the clone status page. */
$clone_job = $data['ACTIVE_CLONE_JOB'] ?? null;
$is_cloning = $clone_job &&
(int) $clone_job['SRC_ACCOUNT'] === $aid;
$group_class = "user-accounts-group" .
($is_active ? " user-accounts-active" : "") .
($is_cloning ? " user-accounts-cloning" : "");
$clone_status_url = $is_cloning ?
$this->mailUrl($data, [
'arg' => 'editAccount',
'account_id' => $aid,
'pane' => 'clone']) : '';
if ($is_cloning) {
/* Edit and delete are not safe while the clone
runs: deleting the source account would orphan
the in-progress job, and renaming changes the
IMAP login credentials the job is mid-use. */
$edit_url = '';
$delete_url = '';
}
/* an account is expanded when it is the active one, or
when the session has no collapsed entry for it (the
default), or when that entry is not collapsed. */
$account_collapsed = $data['ACCOUNT_COLLAPSED'] ?? [];
$collapsed = !$is_active &&
!empty($account_collapsed[$aid]);
$folders_class = "user-accounts-folders" .
($collapsed ? " user-accounts-folders-collapsed" :
"");
/* folders are considered already-loaded server-side
when this is the active account (FOLDERS came back
with the listMessages render) or when an earlier
request cached them in the session; otherwise the
JS will lazy-load on first expansion. */
$cached_folders =
$data['FOLDERS_BY_ACCOUNT'][$aid] ?? [];
$folders_loaded = ($is_active && !empty($data['FOLDERS']))
|| !empty($cached_folders);
/* the triangle reflects whether anything would be
visible if the disclosure is "open": for a not-yet-
loaded account the ul is empty regardless of the
collapsed flag, so showing the expanded glyph would
mislead. point at the account name (right, for
ltr) until folders arrive. the glyphs come from the
locale so rtl and top-bottom languages can pick
appropriate shapes. */
$triangle = ($collapsed || !$folders_loaded) ?
tl('mail_element_folder_triangle_collapsed') :
tl('mail_element_folder_triangle_expanded');
$reorderable = (!$is_mailsite && !$is_cloning);
e('<div class="' . $group_class);
if ($reorderable) {
e(' user-accounts-reorderable');
}
e('"');
if ($reorderable) {
e(' draggable="true" data-account-id="' .
$aid . '"');
}
e('>');
if ($reorderable) {
e('<span class="user-accounts-drag-handle ' .
'float-opposite" aria-hidden="true" title="' .
tl('mail_element_account_drag_label') .
'">⋮⋮</span>');
}
?>
<h3 class="user-accounts-name"
data-account-id="<?= $aid ?>"
data-account-display-name="<?=
$account['DISPLAY_NAME']
?>"
data-edit-url="<?= $edit_url
?>"
data-delete-url="<?=
$delete_url ?>"
data-delete-confirm="<?= tl(
'mail_element_delete_confirm') ?>"<?php
if ($is_cloning) {
e(' data-cloning="1"');
}
if ($is_mailsite) {
e(' data-no-rename="1"');
}
?>>
<span class="user-accounts-toggle"
data-account-id="<?= $aid ?>"
data-collapsed="<?= $collapsed ? '1' : '0'
?>"
data-folders-loaded="<?=
$folders_loaded ? '1' : '0' ?>"
data-toggle-url="<?=
$toggle_url ?>"
data-folders-url="<?=
$folders_url ?>"><span
class="user-accounts-triangle"><?=
$triangle ?></span>
<span class="user-accounts-display-name"><?=
$account['DISPLAY_NAME']
?></span></span>
<?php
if ($is_cloning) {
/* The mail URLs in this element are already
entity-encoded (& separators), so
they are echoed raw; wrapping them in
htmlspecialchars would double-encode the
ampersands (&) and the browser
would see garbage parameter names. The
parens+link sit in a single span so the
flex container in .user-accounts-name
treats them as one item (otherwise the
toggle-span and the parens-and-link
spread across the row via
justify-content: space-between). */
?>
<span class="user-accounts-cloning-badge"
>(<a href="<?= $clone_status_url
?>" class="user-accounts-cloning-link"
><?= tl('mail_element_cloning_link')
?></a>)</span>
<?php
}
?>
</h3>
<ul class="<?= $folders_class ?>"
id="user-accounts-folders-<?= $aid ?>"
data-folder-expand-url="<?=
$folder_expand_url ?>"<?php
if ($is_cloning) {
e(' data-cloning="1"');
}
?>>
<?php
if ($is_active && !empty($data['FOLDERS'])) {
$folders = $data['FOLDERS'];
} else if (!empty($cached_folders)) {
$folders = $cached_folders;
} else {
/* no real folder list yet — leave the ul empty
and let the JS lazy-load on first expansion.
see initMailAccountToggles in mailmessages.js
and userMailListFolders in SocialComponent. */
$folders = [];
}
$active_folder = $data['ACTIVE_FOLDER'] ?? $folder;
self::renderFolderTree($data, $aid, $folders,
$is_active, $active_folder);
?>
</ul>
</div>
<?php
}
?>
</div>
<?php
}
/**
* Renders the Scheduled pseudo-folder <li> for one account's
* sidebar. Always emitted (even when the pending count is zero)
* so the entry is discoverable before the user has ever used
* the feature. The badge appears only when there are pending
* messages; it reuses .user-accounts-folder-unread for visual
* consistency with the unread counts on INBOX/Drafts/etc.
*
* Lives as a separate helper rather than inline in render()
* so the AJAX userMailListFolders path can include the entry
* in its rendered HTML. Without that, the JS lazy-load's
* innerHTML replacement on the account's folder <ul> would
* wipe the Scheduled entry the first time the user expanded
* a previously-collapsed account.
*
* @param array $data fields including
* SCHEDULED_PENDING_BY_ACCOUNT, ACTIVE_FOLDER,
* CSRF_TOKEN
* @param int $account_id which account's Scheduled entry to
* emit
* @param bool $account_is_active whether this account is the
* one currently selected (drives the active highlight)
*/
public static function renderScheduledFolderEntry($data,
$account_id, $account_is_active)
{
$count = (int) (
$data['SCHEDULED_PENDING_BY_ACCOUNT'][$account_id]
?? 0);
$url = B\controllerUrl("user_mail", true) . C\p('CSRF_TOKEN') .
"=" . $data[C\p('CSRF_TOKEN')] .
"&arg=scheduledList&account_id=" . $account_id;
$is_active = $account_is_active &&
($data['ACTIVE_FOLDER'] ?? '') === 'Scheduled';
$row_class = "user-accounts-folder-row " .
"user-accounts-folder-noselect" .
($is_active ? " user-accounts-folder-active" : "");
?>
<li class="<?= $row_class ?>"><span
class="user-accounts-folder-disclosure"
aria-hidden="true">▸</span><a
href="<?= $url ?>"
class="user-accounts-folder-label"><span
class="user-accounts-folder-icon"
aria-hidden="true">🕑</span><?=
tl('mail_element_folder_scheduled') ?></a><?php
if ($count > 0) { ?><span
class="user-accounts-folder-unread"><?= $count
?></span><?php } ?></li>
<?php
}
/**
* Renders a flat folder list as a nested tree of <ul>/<li>,
* grouping folders by the IMAP hierarchy delimiter that the
* server reports on each LIST row. Children indent under
* their parents; clicking a parent's disclosure arrow
* (managed by the JS) toggles its children's visibility.
*
* Synthetic parent entries are emitted when a child folder
* references a name segment that wasn't itself reported by
* the server -- some Gmail-style namespaces only LIST the
* leaf mailboxes like [Gmail]/Drafts without LISTing the
* [Gmail] root. Those entries render as non-clickable
* rows so they still anchor the tree visually.
*
* @param array $data $data with the CSRF token set
* @param int $account_id the account these folders belong to
* @param array $folders the flat parsed folder list
* @param bool $account_is_active whether this account is the
* one currently selected
* @param string $active_folder the folder currently in view
*/
public static function renderFolderTree($data, $account_id,
$folders, $account_is_active, $active_folder)
{
if (empty($folders)) {
self::renderScheduledFolderEntry($data, $account_id,
$account_is_active);
return;
}
/* pick the dominant delimiter across this account's
folders; per-row delimiters can in theory vary but
in practice every folder in one namespace shares
one. Default to '/' when nothing was reported. */
$delimiter_counts = [];
foreach ($folders as $folder_info) {
$hierarchy = $folder_info['DELIMITER'] ?? '';
if ($hierarchy !== '') {
$delimiter_counts[$hierarchy] =
($delimiter_counts[$hierarchy] ?? 0) + 1;
}
}
$delimiter = '/';
if (!empty($delimiter_counts)) {
arsort($delimiter_counts);
$delimiter = array_key_first($delimiter_counts);
}
$tree = self::buildFolderTree($folders, $delimiter);
$rendered_scheduled = false;
foreach ($tree as $node) {
$is_fixed = strcasecmp($node['NAME'] ?? '',
'INBOX') === 0 ||
!empty($node['SPECIAL_USE']);
if (!$rendered_scheduled && !$is_fixed) {
self::renderScheduledFolderEntry($data,
$account_id, $account_is_active);
$rendered_scheduled = true;
}
self::renderFolderTreeNode($data, $account_id,
$node, $account_is_active, $active_folder,
$delimiter);
}
if (!$rendered_scheduled) {
self::renderScheduledFolderEntry($data, $account_id,
$account_is_active);
}
}
/**
* Builds a nested-tree representation of a flat folder list:
* each node is one folder entry with a CHILDREN key holding
* its descendants. Synthetic parent entries are inserted when
* a child folder references a path segment that wasn't itself
* reported by the server (e.g. Gmail's [Gmail]/Drafts without
* a [Gmail] root). Each level is ordered by
* ImapFolderListParser::sortBySpecialUse so INBOX, Drafts,
* Sent, etc. surface ahead of user folders at every depth.
*
* @param array $folders the flat parsed folder list
* @param string $delimiter the hierarchy delimiter to split
* folder names on
* @return array a list of top-level nodes, each with a
* CHILDREN list of the same shape
*/
protected static function buildFolderTree($folders,
$delimiter)
{
/* keyed by full path so synthetic-parent insertion can
check both real folders and previously-synthesized
ones via the same lookup. */
$by_name = [];
foreach ($folders as $folder_info) {
$by_name[$folder_info['NAME']] = $folder_info;
}
$augmented = [];
foreach ($folders as $folder_info) {
$segments = explode($delimiter,
$folder_info['NAME']);
$prefix = '';
for ($i = 0; $i < count($segments) - 1; $i++) {
$prefix = ($i === 0) ? $segments[0] :
$prefix . $delimiter . $segments[$i];
if (!isset($by_name[$prefix]) &&
!isset($augmented[$prefix])) {
$augmented[$prefix] = ['NAME' => $prefix,
'SELECTABLE' => false,
'SPECIAL_USE' => '',
'DELIMITER' => $delimiter,
'SYNTHETIC' => true];
}
}
$augmented[$folder_info['NAME']] = $folder_info;
}
/* attach each non-root node to its parent's CHILDREN
list; nodes with no slash in their name are roots. */
$nodes = [];
foreach ($augmented as $name => $folder_info) {
$folder_info['CHILDREN'] = [];
$nodes[$name] = $folder_info;
}
$roots = [];
foreach ($nodes as $name => &$node) {
$last_delim = strrpos($name, $delimiter);
if ($last_delim === false) {
$roots[] = &$node;
continue;
}
$parent_name = substr($name, 0, $last_delim);
if (isset($nodes[$parent_name])) {
$nodes[$parent_name]['CHILDREN'][] = &$node;
} else {
/* orphan: parent path doesn't exist even after
synthesis. shouldn't happen, but if it does,
surface the node at the root rather than
silently dropping it. */
$roots[] = &$node;
}
}
unset($node);
return self::sortTreeNodes($roots);
}
/**
* Recursively applies the special-use sort at every level
* of the tree: each siblings list is reordered by
* ImapFolderListParser::sortBySpecialUse, and each node's
* CHILDREN list is recursed into so deeper levels follow the
* same convention.
*
* @param array $nodes a list of tree nodes (each with NAME,
* SPECIAL_USE, and CHILDREN keys)
* @return array the same nodes in sorted order
*/
protected static function sortTreeNodes($nodes)
{
$sorted = ML\ImapFolderListParser::sortBySpecialUse($nodes);
foreach ($sorted as &$node) {
if (!empty($node['CHILDREN'])) {
$node['CHILDREN'] = self::sortTreeNodes(
$node['CHILDREN']);
}
}
unset($node);
return $sorted;
}
/**
* Emits one node and recurses into its children. The node
* opens an <li>; renderFolderItem writes the row content
* (icon + name + disclosure arrow but no closing </li>);
* children render inside a nested <ul>; then we close the
* <li>.
*
* @param array $data $data with the CSRF token set
* @param int $account_id the account this folder belongs to
* @param array $node tree node with CHILDREN list
* @param bool $account_is_active whether this account is the
* one currently selected
* @param string $active_folder the folder currently in view
* @param string $delimiter the hierarchy delimiter
*/
protected static function renderFolderTreeNode($data,
$account_id, $node, $account_is_active, $active_folder,
$delimiter)
{
self::renderFolderItem($data, $account_id, $node,
$account_is_active, $active_folder, $delimiter);
if (!empty($node['CHILDREN'])) {
echo
'<ul class="user-accounts-folder-children">';
foreach ($node['CHILDREN'] as $child) {
self::renderFolderTreeNode($data, $account_id,
$child, $account_is_active, $active_folder,
$delimiter);
}
e('</ul>');
}
e('</li>');
}
/**
* Renders one folder entry in an account's folder list. A
* selectable folder is a link that opens that folder; a folder
* carrying the \Noselect attribute is shown as plain text since
* it cannot be opened. The folder currently being viewed (only
* possible within the active account) gets the active class.
*
* @param array $data $data with the CSRF token set
* @param int $account_id the id of the account this folder
* belongs to
* @param array $folder_info one parsed folder with NAME,
* SELECTABLE, SPECIAL_USE, and optionally SYNTHETIC keys
* @param bool $account_is_active whether this folder's account
* is the one currently selected
* @param string $active_folder the name of the folder currently
* being viewed
* @param string $delimiter the hierarchy delimiter, used to
* split the folder name into display segments
*/
public static function renderFolderItem($data, $account_id,
$folder_info, $account_is_active, $active_folder,
$delimiter = '/')
{
$name = $folder_info['NAME'];
$selectable = !empty($folder_info['SELECTABLE']);
$is_synthetic = !empty($folder_info['SYNTHETIC']);
$special = $folder_info['SPECIAL_USE'] ?? '';
$is_active = $account_is_active && $name === $active_folder;
/* Display values are HTML-cleaned in the controller
(SocialComponent::userMailDecorateFolderDisplay) and
echoed raw here. Real folders carry NAME_HTML /
DELIMITER_HTML / DISPLAY_NAME directly; synthetic parent
nodes inserted during tree building do not, so their
label comes from the FOLDER_DISPLAY map (keyed by the
raw full name) and their name/delimiter attributes fall
back to the cleaned map / dominant delimiter. The raw
$name is kept untouched for folder URLs and IMAP. */
$display_map = $data['FOLDER_DISPLAY'] ?? [];
$map_entry = $display_map[$name] ?? null;
$name_html = $folder_info['NAME_HTML'] ??
($map_entry['NAME_HTML'] ?? $name);
$delimiter_html = $folder_info['DELIMITER_HTML'] ??
($map_entry['DELIMITER_HTML'] ?? $delimiter);
$display_name = $folder_info['DISPLAY_NAME'] ??
($map_entry['DISPLAY'] ?? $name);
/* a folder renders expanded when (a) the user has
previously expanded it (recorded in the session map),
or (b) the active folder is a descendant of it (so
opening a nested folder auto-reveals its ancestor
chain). Otherwise it starts collapsed at page load. */
$folder_expanded =
$data['FOLDER_EXPANDED'][$account_id] ?? [];
$is_expanded = !empty($folder_expanded[$name]);
if (!$is_expanded && $account_is_active &&
$active_folder !== '' && $active_folder !== $name) {
$prefix = $name . $delimiter;
if (strncmp($active_folder, $prefix,
strlen($prefix)) === 0) {
$is_expanded = true;
}
}
$expanded_class = $is_expanded ? ' expanded' : '';
if (!$selectable || $is_synthetic) {
?><li class="user-accounts-folder-row
user-accounts-folder-noselect<?= $expanded_class
?>"
data-account-id="<?= $account_id ?>"
data-folder-name="<?= $name_html ?>"
data-folder-delimiter="<?=
$delimiter_html ?>"
data-can-create-child="0"
data-protected="1"><span
class="user-accounts-folder-disclosure"
aria-hidden="true">▸</span><span
class="user-accounts-folder-label"><span
class="user-accounts-folder-icon">📁</span> <?=
$display_name ?></span><?php
return;
}
$folder_url = self::mailUrl($data,
['arg' => 'listMessages', 'account_id' => $account_id,
'folder' => $name]);
$is_protected = ($special !== '') ||
(strcasecmp($name, 'INBOX') === 0);
/* distinct icons for the standard RFC 6154 special-use
folders + INBOX, so the user can tell built-in folders
apart from their own custom folders at a glance. any
folder not in this table is treated as user-created
and gets the generic folder glyph. */
$icon = '📁';
if (strcasecmp($name, 'INBOX') === 0) {
$icon = '📥';
} else if (strcasecmp($special, '\\Sent') === 0) {
$icon = '📤';
} else if (strcasecmp($special, '\\Trash') === 0) {
$icon = '🗑';
} else if (strcasecmp($special, '\\Drafts') === 0) {
$icon = '✏';
} else if (strcasecmp($special, '\\Junk') === 0) {
$icon = '🚫';
} else if (strcasecmp($special, '\\Archive') === 0) {
$icon = '🗃';
} else if (strcasecmp($special, '\\All') === 0) {
$icon = '📚';
} else if (strcasecmp($special, '\\Flagged') === 0) {
$icon = '⭐';
}
$unread = isset($folder_info['UNREAD']) ?
(int) $folder_info['UNREAD'] : 0;
?><li class="user-accounts-folder-row<?= $expanded_class
?>"
data-account-id="<?= $account_id ?>"
data-folder-name="<?= $name_html ?>"
data-folder-delimiter="<?=
$delimiter_html ?>"
data-can-create-child="1"
data-protected="<?= $is_protected ? '1' : '0' ?>"><span
class="user-accounts-folder-disclosure"
aria-hidden="true">▸</span><a href="<?=
$folder_url ?>"<?= $is_active ?
' class="user-accounts-folder-active"' : '' ?>><span
class="user-accounts-folder-icon"><?= $icon
?></span> <span
class="user-accounts-folder-name"><?=
$display_name ?></span><?php
if ($unread > 0) {
?><span class="user-accounts-folder-unread"><?=
$unread ?></span><?php
}
?></a><?php
}
/**
* Renders the account-messages content pane, dispatching on
* $data['CONTENT_PANE'] to one of the five sub-renderers.
*
* @param array $data fields with CONTENT_PANE set
*/
protected function renderAccountMessages($data)
{
$pane = $data['CONTENT_PANE'] ?? 'selectAccount';
if ($pane === 'addAccount' || $pane === 'editAccount') {
$this->renderAccountForm($data);
} else if ($pane === 'editMailsite') {
$this->renderMailsiteForm($data);
} else if ($pane === 'inbox') {
$this->renderInbox($data);
} else if ($pane === 'message') {
$this->renderMessage($data);
} else if ($pane === 'compose') {
$this->renderCompose($data);
} else if ($pane === 'scheduledList') {
$this->renderScheduledList($data);
} else {
$this->renderSelectAccount($data);
}
}
/**
* Renders the "select an account on the left" placeholder
* shown in the content pane when no account is selected and
* no form is active. Mirrors the user-messages-warning pattern
* from UsermessagesElement.
*
* @param array $data fields including EXTERNAL_ENABLED, ACCOUNTS
*/
protected function renderSelectAccount($data)
{
$external = !empty($data['EXTERNAL_ENABLED']);
$has_accounts = !empty($data['ACCOUNTS']);
$add_url = $this->mailUrl($data, ['arg' => 'addAccount']);
if ($external && !$has_accounts) {
?>
<div class="mail-empty-pane">
<a class="button-box" href="<?= $add_url ?>"><?=
tl('mail_element_add_account') ?></a>
</div>
<?php
return;
}
if ($external && $has_accounts) {
?>
<div class="mail-empty-pane">
<p class="user-mail-warning"><?=
tl('mail_element_select_account') ?></p>
<p class="mail-empty-or"><?=
tl('mail_element_or') ?></p>
<a class="button-box" href="<?= $add_url ?>"><?=
tl('mail_element_add_account') ?></a>
</div>
<?php
return;
}
?>
<p class="user-mail-warning"><?=
tl('mail_element_select_account') ?></p>
<?php
}
/**
* Renders the MailSite account properties pane, reached from the
* edit pencil on the local MailSite account row. Its one section
* is alias management: a list of the user's current aliases each
* with a remove control, and an add form pairing a local-part
* field with a dropdown of the configured mail domains. Aliases
* are stored as a local-part that is valid at every configured
* domain; the dropdown only chooses which domain to show in the
* confirmation, since the stored alias is domain-independent.
*
* @param array $data fields including ALIASES (the user's alias
* local-parts), MAIL_DOMAINS_LIST (configured domains),
* optionally ALIAS_ERROR, and the CSRF token
*/
protected function renderMailsiteForm($data)
{
$back_url = $this->mailUrl($data);
$submit_url = $this->mailUrl($data,
['arg' => 'aliasAction']);
$aliases = $data['ALIASES'] ?? [];
$domains = $data['MAIL_DOMAINS_LIST'] ?? [];
$error = $data['ALIAS_ERROR'] ?? '';
$heading = $data['MAILSITE_DISPLAY_NAME'] ?? '';
if ($heading === '') {
$heading = tl('mail_element_mailsite_heading');
}
?>
<?= $this->view->helper("close")->render($back_url) ?>
<h2><?= $heading ?></h2>
<h3><?= tl('mail_element_alias_heading') ?></h3>
<p><?= tl('mail_element_alias_explain') ?></p>
<?php
if ($error !== '') {
?>
<p class="red"><?= $error ?></p>
<?php
}
if (empty($aliases)) {
?>
<p class="mail-alias-empty"><?=
tl('mail_element_alias_none') ?></p>
<?php
} else {
?>
<div class="mail-alias-list">
<table class="mail-alias-table">
<?php
foreach ($aliases as $alias) {
$remove_url = $this->mailUrl($data, [
'arg' => 'aliasAction',
'alias_action' => 'remove',
'alias' => $alias['ALIAS'],
'alias_domain' => $alias['DOMAIN']]);
?>
<tr>
<td class="mail-alias-name"><?= $alias['ADDRESS']
?></td>
<td><a class="gray-link"
href="<?= $remove_url ?>"><?=
tl('mail_element_alias_remove') ?></a></td>
</tr>
<?php
}
?>
</table>
</div>
<?php
}
?>
<form method="post" action="<?= $submit_url ?>"
class="top-margin mail-alias-form">
<input type="hidden" name="<?= C\p('CSRF_TOKEN') ?>"
value="<?= $data[C\p('CSRF_TOKEN')] ?>">
<input type="hidden" name="alias_action" value="add">
<input type="text" name="alias"
class="mail-alias-input"
placeholder="<?= tl(
'mail_element_alias_placeholder') ?>"
aria-label="<?= tl(
'mail_element_alias_placeholder') ?>">
<span class="mail-alias-at">@</span>
<select name="alias_domain"
class="mail-alias-domain"><?php
foreach ($domains as $domain) {
?>
<option value="<?= $domain ?>"><?= $domain
?></option><?php
}
?></select>
<button type="submit" class="button-box mail-alias-add"
title="<?= tl('mail_element_alias_add') ?>"
aria-label="<?= tl('mail_element_alias_add')
?>">+</button>
</form>
<?php
}
/**
* Renders the Add Account or Edit Account form. The two share
* a single template; $data['CONTENT_PANE'] selects which
* submit URL gets built and whether the password field shows
* a "leave blank" placeholder.
*
* @param array $data fields including FORM_VALUES, optionally
* FORM_ERRORS, ACCOUNT_ID (for edit)
*/
protected function renderAccountForm($data)
{
$is_edit = ($data['CONTENT_PANE'] === 'editAccount');
$values = $data['FORM_VALUES'] ?? [];
$errors = $data['FORM_ERRORS'] ?? [];
$submit_extra = ['arg' => $is_edit ? 'editAccount' : 'addAccount',
'save' => 'save'];
if ($is_edit) {
$submit_extra['account_id'] = $data['ACCOUNT_ID'];
}
$submit_url = $this->mailUrl($data, $submit_extra);
$back_url = $this->mailUrl($data);
$show_clone = $is_edit && !empty($data['MAILSITE_ENABLED']);
$active_job = $data['ACTIVE_CLONE_JOB'] ?? null;
$failed_job = $data['FAILED_CLONE_JOB'] ?? null;
$banner_job = $active_job ?: $failed_job;
$clone_pane = $show_clone &&
!empty($data['CLONE_PANE_ACTIVE']);
if ($show_clone && $banner_job) {
/* Active clone in flight, or a just-failed clone the
user can retry: replace the whole edit pane with the
status banner so an in-flight job cannot be doubled
and a failed one stays visible with its Retry action
and error list rather than vanishing. */
?>
<?= $this->view->helper("close")->render($back_url) ?>
<?php
$this->renderCloneStatusBanner($data, $banner_job);
return;
}
if ($clone_pane) {
/* User clicked the Clone-to-Local-Mailbox link below
the Name field. Render only the clone form, with a
closeHelper [x] at the top-right that navigates back
to the bare Edit Mail Account URL (no pane=clone),
which re-renders the settings form. */
$edit_url = $this->mailUrl($data, [
'arg' => 'editAccount',
'account_id' => $data['ACCOUNT_ID']]);
?>
<?= $this->view->helper("close")->render($edit_url) ?>
<?php
$this->renderCloneForm($data);
return;
}
?>
<?= $this->view->helper("close")->render($back_url) ?>
<h2><?= $is_edit ? tl('mail_element_edit_account_heading') :
tl('mail_element_add_account_heading') ?>
<?= $this->view->helper("helpbutton")->render(
"Mail Account", $data[C\p('CSRF_TOKEN')]) ?>
</h2>
<form method="post" action="<?= $submit_url ?>"
class="top-margin mail-account-form">
<table>
<tr><th class="table-label">
<label for="ma-provider"><?=
tl('mail_element_field_provider') ?>:</label>
</th><td>
<?php
$current_provider = $values['PROVIDER'] ?? '';
?>
<select id="ma-provider" name="provider"
data-username-text="<?= tl(
'mail_element_field_username') ?>"
data-email-text="<?= tl(
'mail_element_field_email') ?>">
<option value=""<?= $current_provider === '' ?
' selected' : '' ?>><?=
tl('mail_element_provider_custom') ?></option>
<?php
foreach (MailAccountModel::PROVIDER_PRESETS
as $name => $preset) {
$selected = ($current_provider === $name) ?
' selected' : '';
?><option value="<?= $name
?>"<?= $selected ?>
data-imap-host="<?= $preset['imap_host'] ?>"
data-imap-port="<?= $preset['imap_port'] ?>"
data-imap-tls="<?= $preset['imap_tls'] ?>"
data-smtp-host="<?= $preset['smtp_host'] ?>"
data-smtp-port="<?= $preset['smtp_port'] ?>"
data-smtp-tls="<?= $preset['smtp_tls'] ?>"
data-email-domain="<?=
(string) ($preset['email_domain'] ?? '') ?>"
data-login-label="<?=
$preset['login_label'] ?>"><?=
$name ?></option><?php
}
?>
</select>
</td></tr>
<tr><th class="table-label">
<label for="ma-display-name"><?=
tl('mail_element_field_display_name') ?>:</label>
</th><td>
<input id="ma-display-name" type="text"
name="display_name"
value="<?= $values['DISPLAY_NAME'] ?? '' ?>"
maxlength="80" class="narrow-field" >
<?= $this->renderFieldError($errors, 'DISPLAY_NAME') ?>
</td></tr>
<?php
if ($show_clone) {
$clone_url = $this->mailUrl($data, [
'arg' => 'editAccount',
'account_id' => $data['ACCOUNT_ID'],
'pane' => 'clone']);
?>
<tr><th class="table-label"></th><td>
<a href="<?= $clone_url ?>"
class="mail-clone-open-link"><?=
tl('mail_element_clone_open_link') ?></a>
</td></tr>
<?php
}
$preset_hidden_class = $current_provider !== '' ?
' none' : '';
?>
<tr class="mail-account-preset-field<?= $preset_hidden_class
?>"><th colspan="2" class="mail-section-label"><?=
tl('mail_element_imap_section') ?></th></tr>
<tr class="mail-account-preset-field<?= $preset_hidden_class
?>"><th class="table-label">
<label for="ma-host"><?=
tl('mail_element_field_host') ?>:</label>
</th><td>
<input id="ma-host" type="text" name="host"
value="<?= $values['HOST'] ?? '' ?>"
maxlength="255" class="narrow-field" >
<?= $this->renderFieldError($errors, 'HOST') ?>
</td></tr>
<tr class="mail-account-preset-field<?= $preset_hidden_class
?>"><th class="table-label">
<label for="ma-port"><?=
tl('mail_element_field_port') ?>:</label>
</th><td>
<input id="ma-port" type="number" name="port"
value="<?= (int) ($values['PORT'] ?? 993) ?>"
min="1" max="65535" >
<?= $this->renderFieldError($errors, 'PORT') ?>
</td></tr>
<tr><th class="table-label">
<label for="ma-username" id="ma-username-label"><?php
$login_label = 'username';
$preset = MailAccountModel::PROVIDER_PRESETS[
$current_provider] ?? null;
if ($preset && isset($preset['login_label'])) {
$login_label = $preset['login_label'];
}
e($login_label === 'email' ?
tl('mail_element_field_email') :
tl('mail_element_field_username'));
?>:</label>
</th><td>
<input id="ma-username" type="text" name="username"
value="<?= $values['USERNAME'] ?? '' ?>"
maxlength="255" class="narrow-field" >
<?= $this->renderFieldError($errors, 'USERNAME') ?>
</td></tr>
<tr><th class="table-label">
<label for="ma-password"><?=
tl('mail_element_field_password') ?>:</label>
</th><td>
<input id="ma-password" type="password" name="password"
value="<?= $values['PASSWORD'] ?? '' ?>"
autocomplete="new-password"
class="narrow-field" >
<?= $this->renderFieldError($errors, 'PASSWORD') ?>
</td></tr>
<tr class="mail-account-preset-field<?= $preset_hidden_class
?>"><th class="table-label">
<label for="ma-tls-mode"><?=
tl('mail_element_field_tls_mode') ?>:</label>
</th><td>
<select id="ma-tls-mode" name="tls_mode">
<?php
$tls = $values['TLS_MODE'] ?? 'imaps';
foreach (['imaps' => tl('mail_element_imaps'),
'starttls' => tl('mail_element_starttls'),
'plain' => tl('mail_element_plain')] as $key =>
$label) {
$sel = ($tls === $key) ?
' selected="selected"' : '';
?><option value="<?= $key ?>"<?= $sel ?>><?=
$label ?></option><?php
}
?>
</select>
</td></tr>
<tr class="mail-account-preset-field<?= $preset_hidden_class
?>"><th colspan="2" class="mail-section-label"><?=
tl('mail_element_smtp_section') ?></th></tr>
<tr class="mail-account-preset-field<?= $preset_hidden_class
?>"><th class="table-label">
<label for="ma-smtp-host"><?=
tl('mail_element_field_host') ?>:</label>
</th><td>
<input id="ma-smtp-host" type="text" name="smtp_host"
value="<?= $values['SMTP_HOST'] ?? '' ?>"
maxlength="255" class="narrow-field" >
<?= $this->renderFieldError($errors, 'SMTP_HOST') ?>
</td></tr>
<tr class="mail-account-preset-field<?= $preset_hidden_class
?>"><th class="table-label">
<label for="ma-smtp-port"><?=
tl('mail_element_field_port') ?>:</label>
</th><td>
<input id="ma-smtp-port" type="number" name="smtp_port"
value="<?= (int) ($values['SMTP_PORT'] ?? 587) ?>"
min="1" max="65535" >
<?= $this->renderFieldError($errors, 'SMTP_PORT') ?>
</td></tr>
<tr class="mail-account-preset-field<?= $preset_hidden_class
?>"><th class="table-label">
<label for="ma-smtp-username"><?=
tl('mail_element_field_username') ?>:</label>
</th><td>
<input id="ma-smtp-username" type="text"
name="smtp_username"
value="<?= $values['SMTP_USERNAME'] ?? '' ?>"
maxlength="255" class="narrow-field" >
</td></tr>
<tr class="mail-account-preset-field<?= $preset_hidden_class
?>"><th class="table-label">
<label for="ma-smtp-password"><?=
tl('mail_element_field_password') ?>:</label>
</th><td>
<input id="ma-smtp-password" type="password"
name="smtp_password"
value="<?= $values['SMTP_PASSWORD'] ?? '' ?>"
autocomplete="new-password"
class="narrow-field" >
</td></tr>
<tr class="mail-account-preset-field<?= $preset_hidden_class
?>"><th class="table-label">
<label for="ma-smtp-tls-mode"><?=
tl('mail_element_field_tls_mode') ?>:</label>
</th><td>
<select id="ma-smtp-tls-mode" name="smtp_tls_mode">
<?php
$smtp_tls = $values['SMTP_TLS_MODE'] ?? 'starttls';
foreach (['smtps' => tl('mail_element_smtps'),
'starttls' => tl('mail_element_starttls'),
'plain' => tl('mail_element_plain')] as $key =>
$label) {
$sel = ($smtp_tls === $key) ?
' selected="selected"' : '';
?><option value="<?= $key ?>"<?= $sel ?>><?=
$label ?></option><?php
}
?>
</select>
</td></tr>
<tr class="mail-account-preset-field<?= $preset_hidden_class
?>"><th class="table-label">
<label for="ma-allow-self-signed"><?=
tl('mail_element_field_allow_self_signed') ?>
<span class="mail-form-label-scope"><?=
tl('mail_element_field_allow_self_signed_scope')
?>:</span></label>
</th><td>
<input id="ma-allow-self-signed" type="checkbox"
name="allow_self_signed" value="1"
<?= empty($values['ALLOW_SELF_SIGNED']) ?
'' : 'checked="checked"' ?> >
</td></tr>
<tr><td colspan="2">
<div class="mail-form-actions">
<button type="submit" class="button-box"><?=
tl('mail_element_save') ?></button>
<a class="button-box" href="<?= $back_url ?>"><?=
tl('mail_element_cancel') ?></a>
</div>
</td></tr>
</table>
</form>
<?php
}
/**
* Renders the clone-submission form as a full replacement of
* the settings form on the Edit Account page. Reached by
* clicking the "Clone to Local Mailbox" link below the Name
* field, which sets pane=clone on the editAccount URL; the
* renderAccountForm dispatcher detects that flag and routes
* here instead of rendering the settings form. The
* closeHelper [x] at the top-right (rendered by the caller)
* navigates back to the bare editAccount URL, which
* re-renders the settings form.
*
* Renders one of two inner states:
* - MailClone media job disabled in Configure Media Jobs:
* explanatory paragraph + disabled button so the user
* understands why submission is unavailable and what
* admin action would unblock it.
* - Otherwise: the active form with three mutually-
* exclusive mode radios (add / wipe / dry-run),
* destination user (read-only, fixed to current Yioop
* username), Start Clone button, and a close [x]
* button.
*
* @param array $data view data
*/
protected function renderCloneForm($data)
{
$enabled = !empty($data['MAILCLONE_ENABLED']);
$back_url = $this->mailUrl($data, [
'arg' => 'editAccount',
'account_id' => $data['ACCOUNT_ID']]);
$username = $data['USER_NAME'] ?? '';
?>
<h2><?= tl('mail_element_clone_heading',
$username) ?>
<?= $this->view->helper("helpbutton")->render(
"Mail Clone", $data[C\p('CSRF_TOKEN')]) ?>
</h2>
<p><?= tl('mail_element_clone_explain') ?></p>
<?php
if (!$enabled) {
?>
<p><?= tl('mail_element_clone_disabled_explain')
?></p>
<form class="top-margin mail-account-form">
<div class="mail-form-actions">
<button type="button" class="button-box" disabled
title="<?= tl(
'mail_element_clone_disabled_tooltip')
?>"><?= tl('mail_element_clone_button')
?></button>
<a class="button-box" href="<?= $back_url ?>"><?=
tl('mail_element_cancel') ?></a>
</div>
</form>
<?php
return;
}
$submit_url = $this->mailUrl($data, [
'arg' => 'startClone',
'account_id' => $data['ACCOUNT_ID']]);
$confirm_wipe = tl(
'mail_element_clone_wipe_confirm_prompt');
?>
<form method="post" action="<?= $submit_url ?>"
class="top-margin mail-account-form mail-clone-form"
data-confirm-wipe="<?=
$confirm_wipe ?>">
<table>
<tr><th class="table-label">
<label><?= tl('mail_element_clone_mode_label')
?>:</label>
</th><td>
<label>
<input type="radio" name="clone_mode"
value="add" checked="checked" >
<?= tl('mail_element_clone_mode_add') ?>
</label>
<br>
<label>
<input type="radio" name="clone_mode"
value="wipe" >
<?= tl('mail_element_clone_mode_wipe') ?>
</label>
<br>
<label>
<input type="radio" name="clone_mode"
value="dry_run" >
<?= tl('mail_element_clone_mode_dry_run') ?>
</label>
</td></tr>
<tr><th class="table-label">
<label><?= tl('mail_element_clone_cap_label')
?>:</label>
</th><td>
<select name="folder_cap"
class="mail-clone-folder-cap">
<option value="0" selected="selected">ALL</option>
<option value="1000">1000</option>
<option value="5000">5000</option>
<option value="10000">10000</option>
<option value="50000">50000</option>
</select>
</td></tr>
<tr><td colspan="2">
<div class="mail-form-actions">
<button type="submit" class="button-box"><?=
tl('mail_element_clone_button') ?></button>
<a class="button-box" href="<?= $back_url ?>"><?=
tl('mail_element_cancel') ?></a>
</div>
</td></tr>
</table>
</form>
<?php
}
/**
* Renders the in-progress / done / failed status banner
* for an active clone job. Polled in the background by the
* mailclone.js script (5-second interval). Display values
* (folder name, status) are cleaned in the controller and
* echoed raw here; counters are cast to int.
*
* @param array $data view data (CSRF token, URL helpers)
* @param array $job the active MAIL_CLONE_JOB row
*/
protected function renderCloneStatusBanner($data, $job)
{
$cancel_url = $this->mailUrl($data, [
'arg' => 'cancelClone',
'job_id' => (int) $job['ID']]);
$retry_url = $this->mailUrl($data, [
'arg' => 'retryClone',
'job_id' => (int) $job['ID']]);
$status_url = $this->mailUrl($data,
['arg' => 'cloneStatus']);
$status_token = $job['STATUS'];
$username = $data['USER_NAME'] ?? '';
?>
<h2><?= tl('mail_element_clone_heading',
$username) ?>
<?= $this->view->helper("helpbutton")->render(
"Mail Clone", $data[C\p('CSRF_TOKEN')]) ?>
</h2>
<?php
/* Persistent red warning beneath the heading when
either daemon is down -- this is not a flash, it
stays visible the entire time the user is on the
page. mailclone.js refreshes the same warning at
each status-poll interval by toggling the .none
class so the warning reappears if MediaUpdater or
MailServer crashes mid-clone. */
$updater_down = empty($data['MEDIA_UPDATER_RUNNING']);
$server_down = empty($data['MAIL_SERVER_RUNNING']);
?>
<p class="red mail-clone-warn-updater<?=
$updater_down ? '' : ' none' ?>">
<?= tl('mail_element_clone_updater_warn') ?>
</p>
<p class="red mail-clone-warn-server<?=
$server_down ? '' : ' none' ?>">
<?= tl('mail_element_clone_server_warn') ?>
</p>
<?php
/* Direct link to the admin Manage Machines page so the
user can start MediaUpdater / MailServer when either is
down. Shown whenever a daemon is down; mailclone.js
toggles the surrounding warnings on each status poll,
and this link sits with them so the path to fix it is
one click away. */
$machines_url = htmlentities(B\controllerUrl("admin",
true)) . C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')] .
"&a=manageMachines";
?>
<p class="mail-clone-manage-machines<?=
($updater_down || $server_down) ? '' : ' none' ?>">
<a href="<?= $machines_url ?>"><?=
tl('mail_element_clone_manage_machines') ?></a>
</p>
<div class="top-margin mail-account-form mail-clone-status"
data-job-id="<?= (int) $job['ID'] ?>"
data-poll-url="<?= $status_url ?>">
<table>
<tr><th class="table-label">
<?= tl('mail_element_clone_status_label') ?>
</th><td>
<span class="mail-clone-status-text"><?=
$status_token ?></span>
</td></tr>
<tr><th class="table-label">
<?= tl('mail_element_clone_status_folder') ?>:
</th><td>
<span class="mail-clone-current-folder"><?=
($job['CURRENT_FOLDER'] ?: '-') ?></span>
</td></tr>
<tr><th class="table-label">
<?= tl('mail_element_clone_status_imported') ?>:
</th><td>
<span class="mail-clone-imported"><?=
(int) $job['IMPORTED'] ?></span>
</td></tr>
<tr><th class="table-label">
<?= tl('mail_element_clone_status_skipped') ?>:
</th><td>
<span class="mail-clone-skipped"><?=
(int) $job['SKIPPED'] ?></span>
</td></tr>
<tr><th class="table-label">
<?= tl('mail_element_clone_status_failed') ?>:
</th><td>
<?php $has_failures = ((int) $job['FAILED']) > 0; ?>
<a href="#" class="mail-clone-failed-link<?=
$has_failures ? '' : ' none' ?>"><?=
(int) $job['FAILED'] ?></a>
<span class="mail-clone-failed-plain<?=
$has_failures ? ' none' : '' ?>"><?=
(int) $job['FAILED'] ?></span>
</td></tr>
</table>
<div class="mail-clone-errors-row none">
<div class="mail-clone-errors"
data-resolved-via-template="<?=
$data['CLONE_ERROR_RESOLVED_VIA_TEMPLATE'] ?? ''
?>"></div>
</div>
<?php
if (in_array($status_token,
['pending', 'running'], true)) {
?>
<div class="mail-form-actions">
<form method="post"
action="<?= $cancel_url ?>"
class="mail-clone-cancel-form">
<button type="submit"
class="button-box"><?= tl(
'mail_element_clone_cancel')
?></button>
</form>
</div>
<?php
} else if ($status_token === 'failed') {
?>
<div class="mail-form-actions">
<form method="post"
action="<?= $retry_url ?>"
class="mail-clone-retry-form">
<button type="submit"
class="button-box"><?= tl(
'mail_element_clone_retry')
?></button>
</form>
</div>
<?php
}
?>
</div>
<?php
}
/**
* Renders a small inline error message for a form field; emits
* nothing when the field has no error.
*
* @param array $errors map of field name => message
* @param string $field name of the field
* @return string the rendered span, or empty string
*/
protected function renderFieldError($errors, $field)
{
if (empty($errors[$field])) {
return '';
}
return ' <span class="red">' .
$errors[$field] . '</span>';
}
/**
* Renders the inbox listing in the content pane: account name
* in the header, then a bounded, scrollable list of envelopes
* (Subject, From, Date) with each subject linking to the message
* view. The scroll container carries the data- attributes the
* mailmessages.js infinite-scroll handler reads to fetch older
* batches. Surfaces IMAP errors inline.
*
* @param array $data fields including ACCOUNT_ID,
* ACCOUNT_DISPLAY_NAME, MESSAGES, TOTAL_MESSAGES,
* OLDEST_SEQ, IMAP_ERROR (when set)
*/
protected function renderInbox($data)
{
$active_folder = $data['ACTIVE_FOLDER'] ?? '';
$unread_only = !empty($data['UNREAD_ONLY']);
$flagged_only = !empty($data['FLAGGED_ONLY']);
$refresh_args = ['arg' => 'listMessages',
'account_id' => $data['ACCOUNT_ID']];
if ($active_folder !== '') {
$refresh_args['folder'] = $active_folder;
}
if ($unread_only) {
$refresh_args['unread_only'] = '1';
}
if ($flagged_only) {
$refresh_args['flagged_only'] = '1';
}
$refresh_url = $this->mailUrl($data, $refresh_args);
$compose_url = $this->mailUrl($data,
['arg' => 'compose',
'account_id' => $data['ACCOUNT_ID']]);
$back_url = $this->mailUrl($data, ['deselect' => '1']);
$sort = $data['SORT'] ?? ['key' => 'date', 'reverse' => false];
$filter = $data['FILTER'] ?? '';
/* toggle URL inverts the current unread-only flag: when
currently off, the link turns it on, and vice versa.
folder, sort, and filter are preserved so a deliberate
narrowing survives the toggle. */
$unread_toggle_args = ['arg' => 'listMessages',
'account_id' => $data['ACCOUNT_ID']];
if ($active_folder !== '') {
$unread_toggle_args['folder'] = $active_folder;
}
if (!$unread_only) {
$unread_toggle_args['unread_only'] = '1';
}
if ($flagged_only) {
$unread_toggle_args['flagged_only'] = '1';
}
if ($sort['key'] !== 'date') {
$unread_toggle_args['sort_key'] = $sort['key'];
}
if (!empty($sort['reverse'])) {
$unread_toggle_args['sort_reverse'] = '1';
}
if ($filter !== '') {
$unread_toggle_args['filter'] = $filter;
}
$unread_toggle_url = $this->mailUrl($data,
$unread_toggle_args);
/* parallel flagged-only toggle URL; same shape, inverts
the flagged_only bit, preserves everything else. */
$flagged_toggle_args = ['arg' => 'listMessages',
'account_id' => $data['ACCOUNT_ID']];
if ($active_folder !== '') {
$flagged_toggle_args['folder'] = $active_folder;
}
if ($unread_only) {
$flagged_toggle_args['unread_only'] = '1';
}
if (!$flagged_only) {
$flagged_toggle_args['flagged_only'] = '1';
}
if ($sort['key'] !== 'date') {
$flagged_toggle_args['sort_key'] = $sort['key'];
}
if (!empty($sort['reverse'])) {
$flagged_toggle_args['sort_reverse'] = '1';
}
if ($filter !== '') {
$flagged_toggle_args['filter'] = $filter;
}
$flagged_toggle_url = $this->mailUrl($data,
$flagged_toggle_args);
$icon_helper = $this->view->helper('iconlink');
/* mail-refresh-link id stays on the refresh anchor for the
auto-refresh JS hook; the [$id, $url] form of renderButton
threads the id through onto the wrapping div, and the
anchor that elt() finds is the only one inside. */
$refresh_link = $icon_helper->renderButton(
['mail-refresh-link', $refresh_url], 'refresh', '',
true);
$compose_link = $icon_helper->renderButton($compose_url,
'compose', '', true);
$unread_toggle_link = $icon_helper->renderButton(
$unread_toggle_url,
$unread_only ? 'mail_filter_all' :
'mail_filter_unread', '', true);
$back_link = '<span class="mail-back-mobile">' .
$icon_helper->renderButton($back_url, 'back', '',
true) . '</span>';
if (!empty($data['IMAP_ERROR'])) {
?>
<h3><?= $back_link ?> <?=
$data['ACCOUNT_DISPLAY_NAME']
?> <?= $refresh_link
?> <?= $unread_toggle_link
?> <?= $compose_link ?></h3>
<p class="red"><?=
$data['IMAP_ERROR'] ?></p>
<?php
return;
}
$messages = $data['MESSAGES'] ?? [];
/* filter URL preserves current sort so debounce-fire
navigations from the filter input do not lose the
user's column sort. folder is included so the filter
stays scoped to the active folder. */
$filter_url_args = ['arg' => 'listMessages',
'account_id' => $data['ACCOUNT_ID']];
if ($active_folder !== '') {
$filter_url_args['folder'] = $active_folder;
}
if ($sort['key'] !== 'date') {
$filter_url_args['sort_key'] = $sort['key'];
}
if (!empty($sort['reverse'])) {
$filter_url_args['sort_reverse'] = '1';
}
if (!empty($data['UNREAD_ONLY'])) {
$filter_url_args['unread_only'] = '1';
}
if (!empty($data['FLAGGED_ONLY'])) {
$filter_url_args['flagged_only'] = '1';
}
$filter_url = $this->mailUrl($data, $filter_url_args);
/* the filter input markup is the same in the empty and
the populated paths; render it via a closure so the
two branches stay in sync. $filter holds the cleaned
display value (from $data['FILTER']); it is captured
explicitly because a closure does not otherwise see
$data. */
$filter_input = function () use ($filter, $filter_url) {
e('<span class="mail-filter-wrap">' .
'<span class="mail-filter-icon">🔍</span>' .
'<input type="text" class="mail-filter-input" ' .
'id="mail-filter-input" data-filter-url="' .
$filter_url . '" placeholder="' .
tl('mail_element_filter_placeholder') .
'" value="' . $filter . '"');
if ($filter !== '') {
e(' autofocus');
}
e('>');
if ($filter !== '') {
e('<span class="mail-filter-clear" ' .
'id="mail-filter-clear" title="' .
tl('mail_element_filter_clear') .
'">×</span>');
}
e('</span>');
};
$total = (int) ($data['TOTAL_MESSAGES'] ?? 0);
if (empty($messages)) {
?>
<h3 class="mail-inbox-heading">
<span class="mail-inbox-heading-title"><?=
$back_link ?> <?=
$data['ACCOUNT_DISPLAY_NAME'] ?>
<span class="mail-inbox-heading-count">(<?=
$total ?>)</span> <?= $refresh_link
?> <?= $unread_toggle_link
?> <?= $compose_link ?></span>
<?php $filter_input(); ?>
</h3>
<p class="mail-inbox-empty-text gray-text"><?=
$filter !== '' ?
tl('mail_element_no_filter_matches') :
tl('mail_element_empty_inbox') ?></p>
<?php
return;
}
$load_url = B\controllerUrl("user_mail", true) .
C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')] .
"&arg=loadMessages";
if ($active_folder !== '') {
$load_url .= "&folder=" . urlencode($active_folder);
}
$sort_supported = !empty($data['SORT_SUPPORTED']);
$sort_date_only = !empty($data['SORT_DATE_ONLY']);
$sort_mode = $data['SORT_MODE'] ?? 'range';
$sort_offset = (int) ($data['SORT_OFFSET'] ?? 0);
$load_url .= "&mode=" . urlencode($sort_mode);
if (($sort['key'] ?? 'date') !== 'date' ||
!empty($sort['reverse'])) {
$load_url .= "&sort_key=" .
urlencode($sort['key']);
if (!empty($sort['reverse'])) {
$load_url .= "&sort_reverse=1";
}
}
if ($filter !== '') {
$load_url .= "&filter=" . urlencode($filter);
}
if ($unread_only) {
$load_url .= "&unread_only=1";
}
if ($flagged_only) {
$load_url .= "&flagged_only=1";
}
/* a tiny closure that builds the click-to-sort URL for one
column: if the column is already active, the click flips
the reverse flag; otherwise it switches the active sort
to this column with reverse cleared. filter is carried
through so re-sorting does not lose the filter. */
$sort_link_url = function ($key) use ($data, $active_folder,
$sort, $filter, $unread_only, $flagged_only) {
$args = ['arg' => 'listMessages',
'account_id' => $data['ACCOUNT_ID'],
'sort_key' => $key];
if ($active_folder !== '') {
$args['folder'] = $active_folder;
}
if ($sort['key'] === $key && empty($sort['reverse'])) {
$args['sort_reverse'] = '1';
}
if ($filter !== '') {
$args['filter'] = $filter;
}
if ($unread_only) {
$args['unread_only'] = '1';
}
if ($flagged_only) {
$args['flagged_only'] = '1';
}
return $this->mailUrl($data, $args);
};
$subject_url = $sort_link_url('subject');
$from_url = $sort_link_url('from');
$date_url = $sort_link_url('date');
$bulk_url = $this->mailUrl($data, [
'arg' => 'bulkAction',
'account_id' => $data['ACCOUNT_ID'],
'folder' => $data['ACTIVE_FOLDER'] ?? 'INBOX',
]);
$folders = $data['FOLDERS'] ?? [];
$active = $data['ACTIVE_FOLDER'] ?? '';
$accounts = $data['ACCOUNTS'] ?? [];
$folders_by_account = $data['FOLDERS_BY_ACCOUNT'] ?? [];
$current_account_id = (int) $data['ACCOUNT_ID'];
/* Selectable folder names per account for the move
destination, consumed by the inbox script to repopulate
the folder list when the account selector changes. The
NAME_HTML values were cleaned in the controller. The
encoded JSON is htmlspecialchars-escaped here only
because it is being embedded inside a double-quoted HTML
attribute, where a raw JSON double quote would otherwise
close the attribute early; the browser decodes it back
to valid JSON before the script reads it. */
$move_folder_options = [];
foreach ($accounts as $account_row) {
$aid = (int) ($account_row['ID'] ?? -1);
$rows = $folders_by_account[$aid] ?? [];
/* the currently viewed account always has its folder
list in $data['FOLDERS'] (the inbox handler just
fetched it); use that when the per-account cache has
not been populated for it yet, so the move
destination list is never empty for the account in
view. */
if (empty($rows) && $aid === $current_account_id) {
$rows = $folders;
}
$names = [];
foreach ($rows as $folder_row) {
$name = $folder_row['NAME'] ?? '';
if ($name === '' || empty($folder_row['SELECTABLE'])) {
continue;
}
$names[] = $folder_row['NAME_HTML'] ?? $name;
}
$move_folder_options[$aid] = $names;
}
$move_folder_options_json = htmlspecialchars(
json_encode($move_folder_options),
ENT_QUOTES, 'UTF-8');
?>
<div class="mail-inbox-pane">
<h3 class="mail-inbox-heading">
<span class="mail-inbox-heading-title"><?=
$back_link ?> <?=
$data['ACCOUNT_DISPLAY_NAME'] ?>
<span class="mail-inbox-heading-count">(<?=
$total ?>)</span> <?= $refresh_link
?> <?= $unread_toggle_link
?> <?= $compose_link ?></span>
<?php $filter_input(); ?>
</h3>
<form method="post" action="<?= $bulk_url ?>"
class="mail-inbox-form" id="mail-inbox-form">
<input type="hidden" name="<?= C\p('CSRF_TOKEN') ?>"
value="<?= $data[C\p('CSRF_TOKEN')] ?>">
<div class="mail-inbox-bulk-bar"
id="mail-inbox-bulk-bar"
data-count-text="<?= tl(
'mail_element_bulk_count_selected') ?>"
data-confirm-one="<?= tl(
'mail_element_bulk_confirm_one') ?>"
data-confirm-many="<?= tl(
'mail_element_bulk_confirm_many') ?>"
data-move-value="move"
data-current-account="<?= $current_account_id ?>"
data-move-folders="<?= $move_folder_options_json ?>"
data-instant-actions="mark_read,mark_unread,delete">
<button type="button"
id="mail-inbox-bulk-mode-toggle"
class="mail-inbox-bulk-mode-toggle"
aria-pressed="false"
data-label-off="<?= tl(
'mail_element_bulk_mode_off') ?>"
data-label-on="<?= tl(
'mail_element_bulk_mode_on') ?>"
title="<?= tl(
'mail_element_bulk_mode_title') ?>"><?=
tl('mail_element_bulk_mode_off') ?></button>
<span class="mail-inbox-bulk-count
mail-inbox-bulk-selection-required"
id="mail-inbox-bulk-count"></span>
<select id="mail-inbox-bulk-action"
name="bulk_action"
class="mail-inbox-bulk-action
mail-inbox-bulk-selection-required">
<option value=""><?= tl(
'mail_element_bulk_action_placeholder')
?></option>
<option value="mark_read"><?= tl(
'mail_element_bulk_mark_read') ?></option>
<option value="mark_unread"><?= tl(
'mail_element_bulk_mark_unread') ?></option>
<option value="delete"><?= tl(
'mail_element_bulk_delete') ?></option>
<option value="move"><?= tl(
'mail_element_bulk_move_choose') ?></option>
</select>
<select id="mail-inbox-bulk-destination-account"
name="destination_account_id"
class="mail-inbox-bulk-destination-account none"><?php
foreach ($accounts as $account_row) {
$aid = (int) ($account_row['ID'] ?? -1);
if ($aid < 0) {
continue;
}
$label = $account_row['DISPLAY_NAME'] ?? '';
$selected = ($aid === $current_account_id) ?
' selected="selected"' : '';
?>
<option value="<?= $aid ?>"<?= $selected
?>><?= $label ?></option><?php
} ?>
</select>
<select id="mail-inbox-bulk-destination"
name="destination"
data-active-folder="<?= $active ?>"
data-placeholder="<?= tl(
'mail_element_bulk_move_placeholder') ?>"
class="mail-inbox-bulk-destination none">
<option value=""><?= tl(
'mail_element_bulk_move_placeholder')
?></option><?php
foreach ($folders as $folder_row) {
$name = $folder_row['NAME'] ?? '';
if ($name === '' || $name === $active ||
empty($folder_row['SELECTABLE'])) {
continue;
}
$name_html = $folder_row['NAME_HTML'] ?? $name;
?>
<option value="<?= $name_html
?>"><?= $name_html
?></option><?php
} ?>
</select>
<button type="button"
id="mail-inbox-bulk-select-toggle"
class="mail-inbox-bulk-select-toggle
mail-inbox-bulk-mode-required"
aria-pressed="false"
data-label-all="<?= tl(
'mail_element_bulk_select_all_button')
?>"
data-label-none="<?= tl(
'mail_element_bulk_select_none_button')
?>"><?=
tl('mail_element_bulk_select_all_button')
?></button>
</div>
<div id="mail-inbox-scroll" class="mail-inbox-scroll"
data-account-id="<?= (int) $data['ACCOUNT_ID'] ?>"
data-loaded-count="<?= count($messages) ?>"
data-oldest-seq="<?= (int) ($data['OLDEST_SEQ'] ?? 0) ?>"
data-sort-mode="<?= $sort_mode ?>"
data-sort-offset="<?= $sort_offset ?>"
data-load-url="<?= $load_url ?>"
onscroll="handleMailListScroll()">
<table class="mail-inbox-table" id="mail-inbox-table">
<colgroup>
<col id="mail-col-subject">
<col id="mail-col-star">
<col id="mail-col-from">
<col id="mail-col-date">
</colgroup>
<thead>
<tr>
<?php
$sort_php_max = C\nsdefined('MAIL_PHP_SORT_MAX') ?
C\MAIL_PHP_SORT_MAX : 2000;
$folder_size = (int) ($data['TOTAL_MESSAGES'] ?? 0);
/* subject and from are sortable only when the
backend can order by them; a date-only backend
(MailSite today) leaves those two as plain text
while keeping the date header clickable. */
$can_sort_header = !$sort_date_only &&
($sort_supported ||
$folder_size <= $sort_php_max);
?>
<th><?php if ($can_sort_header) { ?><a
class="mail-col-sort-link"
href="<?= $subject_url ?>"><?=
tl('mail_element_col_subject') ?></a><?php
} else { e(tl('mail_element_col_subject'));
} ?><span class="mail-col-resizer"
data-resize-col="mail-col-subject"></span></th>
<th class="mail-col-star-cell"><a
class="mail-col-star-header<?=
$flagged_only ?
' mail-col-star-header-active' :
'' ?>" href="<?= $flagged_toggle_url ?>"
title="<?= tl(
$flagged_only ?
'mail_element_show_all_title' :
'mail_element_show_flagged_title')
?>" aria-label="<?= tl(
$flagged_only ?
'mail_element_show_all_title' :
'mail_element_show_flagged_title') ?>"><?=
$flagged_only ? '★' : '☆'
?></a><span class="mail-col-resizer"
data-resize-col="mail-col-star"></span></th>
<th><?php if ($can_sort_header) { ?><a
class="mail-col-sort-link"
href="<?= $from_url ?>"><?=
tl('mail_element_col_from') ?></a><?php
} else { e(tl('mail_element_col_from'));
} ?><span class="mail-col-resizer"
data-resize-col="mail-col-from"></span></th>
<th><a class="mail-col-sort-link"
href="<?= $date_url ?>"><?=
tl('mail_element_col_date') ?></a></th>
</tr>
</thead>
<tbody id="mail-inbox-rows"><?php
foreach ($messages as $msg) {
e($this->renderInboxRow($data, $msg));
} ?>
</tbody>
</table>
</div>
</form>
</div>
<?php
}
/**
* Renders one inbox table row for a single message envelope.
* Shared by the initial page render in renderInbox() and the
* infinite-scroll fragment built in
* SocialComponent::userMailLoadMessages(); both paths must emit
* the same markup so appended rows match the originals. Returns
* the row as a string rather than echoing so the controller can
* concatenate a batch of them for its JSON response.
*
* @param array $data fields including ACCOUNT_ID and the CSRF
* token, used to build the message-view link
* @param array $msg one envelope with keys UID, SUBJECT, FROM,
* DATE
* @return string the rendered <tr> markup
*/
public static function renderInboxRow($data, $msg)
{
$subject = ($msg['SUBJECT'] ?? '') !== '' ? $msg['SUBJECT'] :
tl('mail_element_no_subject');
$from = ($msg['FROM_NAME'] ?? '') !== '' ?
$msg['FROM_NAME'] : ($msg['FROM'] ?? '');
$date_ts = (int) ($msg['DATE_TS'] ?? 0);
$date_raw = (string) ($msg['DATE'] ?? '');
$date = self::renderInboxDate($date_ts, $date_raw);
$uid = (int) ($msg['UID'] ?? 0);
$answered_glyph = !empty($msg['IS_ANSWERED']) ?
'<span class="mail-inbox-answered" title="' .
tl('mail_element_answered') .
'">↩</span> ' : '';
$is_flagged = !empty($msg['IS_FLAGGED']);
if (!empty($msg['IS_PLACEHOLDER'])) {
/* the envelope could not be fetched (a corrupted
cache entry on the server, typically); render a
non-clickable row so the inbox view stays intact
without offering a link that would just hit the
same server problem on viewMessage. */
return '<tr class="mail-inbox-row-placeholder">' .
'<td>' . $subject . '</td>' .
'<td class="mail-col-star-cell"></td>' .
'<td>' . $from .
'</td><td>' . $date . '</td></tr>';
}
$url_args = ['arg' => 'viewMessage',
'account_id' => $data['ACCOUNT_ID'],
'uid' => $msg['UID']];
if (!empty($data['ACTIVE_FOLDER'])) {
$url_args['folder'] = $data['ACTIVE_FOLDER'];
}
$url = self::mailUrl($data, $url_args);
$flag_url_args = ['arg' => 'toggleFlag',
'account_id' => $data['ACCOUNT_ID'],
'folder' => $data['ACTIVE_FOLDER'] ?? 'INBOX',
'uid' => $msg['UID'],
'flag' => $is_flagged ? '0' : '1'];
$sort = $data['SORT'] ?? ['key' => 'date',
'reverse' => false];
if ($sort['key'] !== 'date') {
$flag_url_args['sort_key'] = $sort['key'];
}
if (!empty($sort['reverse'])) {
$flag_url_args['sort_reverse'] = '1';
}
$filter = $data['FILTER'] ?? '';
if ($filter !== '') {
$flag_url_args['filter'] = $filter;
}
if (!empty($data['UNREAD_ONLY'])) {
$flag_url_args['unread_only'] = '1';
}
if (!empty($data['FLAGGED_ONLY'])) {
$flag_url_args['flagged_only'] = '1';
}
$flag_url = self::mailUrl($data, $flag_url_args);
$star_glyph = $is_flagged ? '★' : '☆';
$star_class = 'mail-inbox-star' . ($is_flagged ?
' mail-inbox-star-filled' : '');
$star_title = $is_flagged ?
tl('mail_element_unstar_title') :
tl('mail_element_star_title');
$tr_class = !empty($msg['IS_UNREAD']) ?
' class="mail-inbox-row-unread"' : '';
return '<tr' . $tr_class . '>' .
'<td>' . $answered_glyph .
'<a class="mail-open-message" href="' . $url . '">' .
$subject . '</a>' .
'<input type="checkbox" class="mail-inbox-check"' .
' name="uids[]" value="' . $uid . '" hidden></td>' .
'<td class="mail-col-star-cell">' .
'<a href="' . $flag_url . '" class="' . $star_class .
'" title="' . $star_title . '" aria-label="' .
$star_title . '">' . $star_glyph . '</a></td>' .
'<td>' . $from . '</td><td>' . $date .
'<button type="button"' .
' class="mail-inbox-row-swipe-delete"' .
' aria-hidden="true" tabindex="-1">' .
tl('mail_element_bulk_delete') . '</button>' .
'</td></tr>';
}
/**
* Formats a message's received timestamp the way Apple Mail
* (and most modern mail clients) do: relative buckets that
* compress as the message ages.
* - today: time of day in the user-locale 24h format
* e.g. "09:37"
* - yesterday: "Yesterday"
* - within 6 days back: weekday name e.g. "Monday"
* - same year: "May 14"
* - earlier: "5/14/25"
* The whole label is wrapped in a <time> element carrying
* the ISO 8601 timestamp in its datetime attribute and the
* full RFC 2822 string in its title attribute, so the user
* can hover to see the precise original. When the timestamp
* is unparseable, falls back to the raw value with no
* <time> wrapper.
*
* Server time is used for the "today / yesterday / weekday"
* bucketing. The Yioop user-base is typically on a single
* timezone configured server-side, so this matches the
* user's experience; a future patch can move the
* relative-time computation to JS for true browser-locale
* accuracy.
*
* @param int $ts Unix timestamp of the message (0 when not
* parseable)
* @param string $raw the original Date header string, used
* as the title attribute and as the fallback when
* $ts is 0
* @return string HTML for the date cell
*/
public static function renderInboxDate($ts, $raw)
{
if ($ts <= 0) {
return $raw;
}
$now = time();
$today_midnight = strtotime('today');
$yesterday_midnight = $today_midnight - 86400;
$seven_days_back = $today_midnight - (6 * 86400);
$this_year = (int) date('Y', $now);
$message_year = (int) date('Y', $ts);
if ($ts >= $today_midnight) {
$label = date('H:i', $ts);
} else if ($ts >= $yesterday_midnight) {
$label = tl('mail_element_date_yesterday');
} else if ($ts >= $seven_days_back) {
$label = date('l', $ts);
} else if ($message_year === $this_year) {
$label = date('M j', $ts);
} else {
$label = date('n/j/y', $ts);
}
return '<time datetime="' . date('c',
$ts) . '" title="' . $raw . '">' .
$label . '</time>';
}
/**
* Renders the Compose Message form. Fields come from
* $data['FORM_VALUES'] (TO, SUBJECT, BODY) so a re-render
* after a failed sendMessage POST preserves what the user
* typed. Per-field validation messages come from
* $data['FORM_ERRORS'] keyed by field name; a SEND key in
* the same array carries an SMTP-level failure description
* shown as a top-of-form red banner.
*
* @param array $data fields with CONTENT_PANE = 'compose',
* ACCOUNT_ID, ACCOUNT_DISPLAY_NAME, FROM_DISPLAY,
* FORM_VALUES, FORM_ERRORS
*/
protected function renderCompose($data)
{
$values = $data['FORM_VALUES'] ?? [];
$errors = $data['FORM_ERRORS'] ?? [];
$inbox_args = ['arg' => 'listMessages',
'account_id' => $data['ACCOUNT_ID']];
$cancel_url = $this->mailUrl($data, $inbox_args);
$form_url = B\controllerUrl("user_mail", true) .
C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')] .
"&arg=sendMessage&account_id=" .
(int) $data['ACCOUNT_ID'];
?>
<div class="mail-compose-pane">
<h3 class="mail-compose-heading"><?=
$data['ACCOUNT_DISPLAY_NAME'] ?> — <?=
tl('mail_element_compose_heading') ?>
<label class="mail-compose-attach-button"
for="mail-compose-attachments"
title="<?= tl('mail_element_compose_attach')
?>"
aria-label="<?= tl(
'mail_element_compose_attach') ?>"
>📎</label></h3>
<?php
if (!empty($errors['SEND'])) { ?>
<p class="red"><?= $errors['SEND'] ?></p><?php
} ?>
<form method="post" action="<?= $form_url ?>"
class="mail-compose-form"
enctype="multipart/form-data">
<?php
$reply_context = $data['REPLY_CONTEXT'] ?? [];
if (!empty($reply_context['KIND'])) {
?>
<input type="hidden" name="reply_kind"
value="<?=
$reply_context['KIND'] ?>">
<input type="hidden" name="reply_uid"
value="<?= (int) ($reply_context['UID'] ?? 0)
?>">
<input type="hidden" name="reply_folder"
value="<?=
$reply_context['FOLDER'] ?? '' ?>">
<input type="hidden" name="reply_message_id"
value="<?=
$reply_context['MESSAGE_ID'] ?? '' ?>">
<input type="hidden" name="reply_references"
value="<?=
$reply_context['REFERENCES'] ?? '' ?>">
<input type="hidden" name="reply_account_id"
value="<?= (int) (
$reply_context['SOURCE_ACCOUNT_ID'] ?? 0) ?>">
<?php
}
$from_options = $data['FROM_OPTIONS'] ?? [];
$from_value = $data['FROM_VALUE'] ?? '';
?>
<div class="mail-compose-row mail-compose-from-row">
<label class="mail-compose-label"
for="mail-compose-from"><?=
tl('mail_element_field_from') ?></label>
<?php
if (count($from_options) > 1) { ?>
<select name="from_option"
id="mail-compose-from"
class="mail-compose-input"><?php
foreach ($from_options as $opt) {
?>
<option value="<?= $opt['VALUE'] ?>"<?=
($opt['VALUE'] === $from_value) ?
' selected' : '' ?>><?=
$opt['ADDRESS'] ?></option><?php
} ?>
</select><?php
} else {
$only = $from_options[0]['ADDRESS'] ??
($data['FROM_DISPLAY'] ?? '');
$only_value = $from_options[0]['VALUE'] ??
$from_value;
?>
<input type="hidden" name="from_option"
value="<?= $only_value ?>">
<span class="mail-compose-from"><?= $only
?></span><?php
} ?>
</div>
<?php
$cc_visible = ($values['CC'] ?? '') !== '' ||
!empty($errors['CC']);
$bcc_visible = ($values['BCC'] ?? '') !== '' ||
!empty($errors['BCC']);
?>
<div class="mail-compose-row mail-compose-to-row">
<div class="mail-compose-field">
<label class="mail-compose-field-label"
for="mail-compose-to"><?=
tl('mail_element_field_to')
?></label><input type="text" name="to"
id="mail-compose-to"
class="mail-compose-input"
autofocus
aria-label="<?= tl(
'mail_element_field_to') ?>"
value="<?= $values['TO'] ?? '' ?>">
<span class="mail-compose-recipient-toggles">
<button type="button"
id="mail-compose-cc-toggle"
class="mail-compose-recipient-toggle<?=
$cc_visible ? ' none' : '' ?>"
data-target="mail-compose-cc-row"
aria-controls="mail-compose-cc-row"
aria-expanded="<?= $cc_visible ?
'true' : 'false' ?>"><?=
tl('mail_element_field_cc')
?></button>
<button type="button"
id="mail-compose-bcc-toggle"
class="mail-compose-recipient-toggle<?=
$bcc_visible ? ' none' : '' ?>"
data-target="mail-compose-bcc-row"
aria-controls="mail-compose-bcc-row"
aria-expanded="<?= $bcc_visible ?
'true' : 'false' ?>"><?=
tl('mail_element_field_bcc')
?></button>
</span>
</div>
<?php
if (!empty($errors['TO'])) { ?>
<span class="red"><?= $errors['TO'] ?></span><?php
} ?>
</div>
<div class="mail-compose-row<?= $cc_visible ? '' :
' none' ?>" id="mail-compose-cc-row">
<div class="mail-compose-field">
<button type="button"
class="mail-compose-field-label
mail-compose-field-collapse"
data-collapse-target="mail-compose-cc-row"
data-restore-toggle="mail-compose-cc-toggle"
aria-label="<?= tl(
'mail_element_field_cc_collapse') ?>"><?=
tl('mail_element_field_cc')
?></button><input type="text" name="cc"
id="mail-compose-cc"
class="mail-compose-input"
aria-label="<?= tl(
'mail_element_field_cc') ?>"
value="<?= $values['CC'] ?? '' ?>">
</div>
<?php
if (!empty($errors['CC'])) { ?>
<span class="red"><?= $errors['CC'] ?></span><?php
} ?>
</div>
<div class="mail-compose-row<?= $bcc_visible ? '' :
' none' ?>" id="mail-compose-bcc-row">
<div class="mail-compose-field">
<button type="button"
class="mail-compose-field-label
mail-compose-field-collapse"
data-collapse-target="mail-compose-bcc-row"
data-restore-toggle="mail-compose-bcc-toggle"
aria-label="<?= tl(
'mail_element_field_bcc_collapse') ?>"><?=
tl('mail_element_field_bcc')
?></button><input type="text" name="bcc"
id="mail-compose-bcc"
class="mail-compose-input"
aria-label="<?= tl(
'mail_element_field_bcc') ?>"
value="<?= $values['BCC'] ?? '' ?>">
</div>
<?php
if (!empty($errors['BCC'])) { ?>
<span class="red"><?= $errors['BCC'] ?></span><?php
} ?>
</div>
<div class="mail-compose-row">
<div class="mail-compose-field">
<label class="mail-compose-field-label"
for="mail-compose-subject"><?=
tl('mail_element_field_subject')
?></label><input type="text" name="subject"
id="mail-compose-subject"
class="mail-compose-input"
aria-label="<?= tl(
'mail_element_field_subject') ?>"
value="<?= $values['SUBJECT'] ?? '' ?>">
</div>
</div>
<div class="mail-compose-row mail-compose-body-row">
<textarea name="body"
id="mail-compose-body"
class="mail-compose-body"
aria-label="<?= tl(
'mail_element_field_body') ?>"
><?= $values['BODY'] ?? '' ?></textarea>
<?php
if (!empty($errors['BODY'])) { ?>
<span class="red"><?= $errors['BODY'] ?></span><?php
} ?>
</div>
<input type="file" multiple
name="attachments[]"
id="mail-compose-attachments"
class="mail-compose-file-input">
<div class="mail-compose-attachments"
id="mail-compose-file-list"></div>
<?php
if (!empty($errors['ATTACH'])) { ?>
<p class="red"><?= $errors['ATTACH'] ?></p><?php
} ?>
<div class="mail-compose-buttons">
<span class="mail-compose-send-split"
id="mail-compose-send-split"
data-schedule-button-label="<?=
tl(
'mail_element_compose_schedule_button')
?>"
data-send-button-label="<?= tl(
'mail_element_compose_send') ?>">
<button type="submit"
id="mail-compose-send-button"
class="mail-compose-button
mail-compose-send-button"><?=
tl('mail_element_compose_send')
?></button>
<button type="button"
id="mail-compose-schedule-toggle"
class="mail-compose-button
mail-compose-schedule-toggle"
aria-expanded="false"
aria-controls="mail-compose-schedule-popover"
aria-label="<?= tl(
'mail_element_compose_schedule') ?>"
title="<?= tl(
'mail_element_compose_schedule') ?>"
>⌄</button>
<div id="mail-compose-schedule-popover"
class="mail-compose-schedule-popover
none" role="menu"
aria-labelledby="mail-compose-schedule-toggle">
<div
class="mail-compose-schedule-heading"><?=
tl(
'mail_element_compose_schedule_heading')
?></div>
<button type="button"
class="mail-compose-schedule-preset"
data-preset="tomorrow_morning"
role="menuitem"><?=
tl(
'mail_element_compose_schedule_tomorrow_morning')
?></button>
<button type="button"
class="mail-compose-schedule-preset"
data-preset="tomorrow_afternoon"
role="menuitem"><?=
tl(
'mail_element_compose_schedule_tomorrow_afternoon')
?></button>
<button type="button"
id="mail-compose-schedule-choose"
class="mail-compose-schedule-preset
mail-compose-schedule-choose"
role="menuitem"><?= tl(
'mail_element_compose_schedule_choose')
?></button>
<div
id="mail-compose-schedule-custom"
class="mail-compose-schedule-custom
none">
<input type="datetime-local"
id="mail-compose-schedule-input"
class="mail-compose-schedule-input">
</div>
</div>
</span>
<a class="mail-compose-button"
href="<?= $cancel_url ?>"><?=
tl('mail_element_compose_cancel')
?></a>
<span id="mail-compose-schedule-status"
class="mail-compose-schedule-status none"
data-pattern="<?= tl(
'mail_element_compose_schedule_pattern')
?>"></span>
<button type="button"
id="mail-compose-schedule-clear"
class="mail-compose-schedule-clear none"
aria-label="<?= tl(
'mail_element_compose_schedule_cancel_choice')
?>" title="<?= tl(
'mail_element_compose_schedule_cancel_choice')
?>">✕</button>
</div>
<input type="hidden" name="scheduled_at"
id="mail-compose-scheduled-at" value="0">
<input type="hidden" name="<?= C\p('CSRF_TOKEN')
?>" value="<?= $data[C\p('CSRF_TOKEN')] ?>">
</form>
</div>
<?php
}
/**
* Renders the per-account Scheduled folder view: a table of
* pending and failed scheduled-send rows, each with target
* time, recipients, subject, status, and a Cancel button.
* Empty state shows a friendly "no scheduled messages" with
* a compose link.
*
* Time formatting uses PHP's date() with a Y-m-d H:i format
* so the rendered times are unambiguous (no MM/DD vs DD/MM
* ambiguity); JS could prettify to "in 2 hours" later but
* the absolute time stays the canonical display so the user
* can verify exactly what they scheduled.
*
* @param array $data fields including SCHEDULED_MESSAGES,
* ACCOUNT_ID, ACCOUNT_DISPLAY_NAME, CSRF_TOKEN
*/
protected function renderScheduledList($data)
{
$account_id = (int) ($data['ACCOUNT_ID'] ?? 0);
$messages = $data['SCHEDULED_MESSAGES'] ?? [];
$cancel_url = B\controllerUrl("user_mail", true) .
C\p('CSRF_TOKEN') . "=" . $data[C\p('CSRF_TOKEN')] .
"&arg=scheduledCancel&account_id=" . $account_id;
?>
<div class="mail-scheduled-pane">
<h3 class="mail-scheduled-heading"><?=
$data['ACCOUNT_DISPLAY_NAME'] ?? '' ?>
— <?=
tl('mail_element_folder_scheduled') ?></h3>
<?php
if (empty($messages)) { ?>
<p class="mail-scheduled-empty"><?=
tl('mail_element_scheduled_empty') ?></p><?php
} else { ?>
<table class="mail-scheduled-table">
<thead><tr>
<th><?= tl('mail_element_scheduled_when')
?></th>
<th><?= tl('mail_element_scheduled_to')
?></th>
<th><?= tl('mail_element_scheduled_subject')
?></th>
<th><?= tl('mail_element_scheduled_status')
?></th>
<th></th>
</tr></thead>
<tbody><?php
foreach ($messages as $message) {
$row_id = (int) $message['ID'];
$when = date('Y-m-d H:i',
(int) $message['SCHEDULED_AT']);
$recipients = $message['TO_LIST_DISPLAY'] ?? '';
$subject = $message['SUBJECT_DISPLAY'] ?? '';
$status = $message['STATUS'] ?? 'pending';
$status_class = "mail-scheduled-status-" .
$status;
if ($status === 'failed') {
$status_label =
tl('mail_element_scheduled_status_failed');
} else if ($status === 'sending') {
$status_label =
tl('mail_element_scheduled_status_sending');
} else {
$status_label =
tl('mail_element_scheduled_status_pending');
}
?>
<tr>
<td><?= $when ?></td>
<td><?= $recipients
?></td>
<td><?= $subject ?></td>
<td><span class="<?= $status_class
?>" title="<?=
$message['LAST_ERROR_DISPLAY'] ?? ''
?>"><?= $status_label
?></span></td>
<td>
<form method="post" action="<?=
$cancel_url ?>"
class="mail-scheduled-cancel-form"
onsubmit="return confirm(<?=
'\'' . addslashes(
tl(
'mail_element_scheduled_cancel_confirm'
)) . '\'' ?>);">
<input type="hidden" name="<?=
C\p('CSRF_TOKEN') ?>" value="<?=
$data[C\p('CSRF_TOKEN')] ?>">
<input type="hidden"
name="scheduled_id" value="<?=
$row_id ?>">
<button type="submit"
class="mail-scheduled-cancel-button"
><?= tl(
'mail_element_scheduled_cancel')
?></button>
</form>
</td>
</tr><?php
} ?>
</tbody>
</table><?php
} ?>
</div>
<?php
}
/**
* Renders a single-message view in the content pane: a Back to
* inbox link, a labeled headers block (Subject, From, To, Cc,
* Date), the body (sanitised HTML if present, plain text
* otherwise), and a list of attachment download links. The
* outer .mail-message-pane wraps the content as a flex column
* so the body can scroll internally while header and
* attachments stay visible -- previously the body's
* max-height: 60vh was viewport-relative and could push the
* attachment list below a resizable pane's visible area.
* IMAP errors are surfaced inline.
*
* @param array $data fields including ACCOUNT_ID, UID,
* MIME_MESSAGE (parsed body), MESSAGE_BODY (raw fallback
* when MIME parsing failed), ACTIVE_FOLDER, IMAP_ERROR
* (when set)
*/
protected function renderMessage($data)
{
$inbox_args = ['arg' => 'listMessages',
'account_id' => $data['ACCOUNT_ID']];
if (!empty($data['ACTIVE_FOLDER'])) {
$inbox_args['folder'] = $data['ACTIVE_FOLDER'];
}
$inbox_url = $this->mailUrl($data, $inbox_args);
$reply_base_args = ['arg' => 'compose',
'account_id' => $data['ACCOUNT_ID'],
'reply_uid' => $data['UID'] ?? 0,
'reply_folder' => $data['ACTIVE_FOLDER'] ?? 'INBOX'];
$reply_url = $this->mailUrl($data,
$reply_base_args + ['reply_kind' => 'reply']);
$reply_all_url = $this->mailUrl($data,
$reply_base_args + ['reply_kind' => 'reply_all']);
$forward_url = $this->mailUrl($data,
$reply_base_args + ['reply_kind' => 'forward']);
$can_reply = !empty($data['MIME_MESSAGE']) &&
empty($data['IMAP_ERROR']);
$can_delete = !empty($data['UID']) &&
!empty($data['ACTIVE_FOLDER']) &&
empty($data['IMAP_ERROR']);
$bulk_url = $this->mailUrl($data, [
'arg' => 'bulkAction',
'account_id' => $data['ACCOUNT_ID'],
'folder' => $data['ACTIVE_FOLDER'] ?? 'INBOX']);
$icon_helper = $this->view->helper('iconlink');
?>
<div class="mail-message-pane">
<div class="mail-message-back">
<a class="gray-link" href="<?= $inbox_url ?>"><?=
tl('mail_element_back_to_folder',
$data['ACTIVE_FOLDER_HTML'] ??
($data['ACTIVE_FOLDER'] ?? 'INBOX'))
?></a>
<?php
if ($can_reply || $can_delete) { ?>
<div class="mail-message-actions"><?php
if ($can_reply) { ?>
<span class="mail-message-actions-reply-group">
<?= $icon_helper->renderButton($reply_url,
'mail_reply', '', true) ?>
<?= $icon_helper->renderButton(
$reply_all_url, 'mail_reply_all', '',
true) ?>
<?= $icon_helper->renderButton($forward_url,
'mail_forward', '', true) ?>
</span><?php
} ?>
<?php
$folders = $data['FOLDERS'] ?? [];
$move_targets = [];
foreach ($folders as $folder_row) {
$name = $folder_row['NAME'] ?? '';
if ($name === '' ||
$name === ($data['ACTIVE_FOLDER']
?? '') ||
empty($folder_row['SELECTABLE'])) {
continue;
}
$move_targets[] = $folder_row['NAME_HTML']
?? $name;
}
if ($can_delete && !empty($move_targets)) {
?>
<form method="post" action="<?= $bulk_url ?>"
class="mail-message-move-form">
<input type="hidden" name="<?= C\p('CSRF_TOKEN')
?>" value="<?= $data[C\p('CSRF_TOKEN')]
?>">
<input type="hidden" name="bulk_action"
value="move">
<input type="hidden" name="uids[]"
value="<?= (int) $data['UID'] ?>">
<select name="destination"
class="mail-message-move-destination"
onchange="if (this.value) {
this.form.submit(); }">
<option value="" selected disabled
hidden><?= tl(
'mail_element_message_move')
?></option><?php
foreach ($move_targets as $name_html) {
?>
<option value="<?=
$name_html ?>"><?=
$name_html ?></option><?php
} ?>
</select>
</form><?php
} ?><?php
if ($can_delete) { ?>
<span class="mail-message-actions-trash-group">
<form method="post" action="<?= $bulk_url ?>"
class="mail-message-delete-form"
onsubmit="return confirm(<?=
'\'' . addslashes(tl(
'mail_element_bulk_confirm_one')) . '\''
?>);">
<input type="hidden" name="<?= C\p('CSRF_TOKEN')
?>" value="<?= $data[C\p('CSRF_TOKEN')]
?>">
<input type="hidden" name="bulk_action"
value="delete">
<input type="hidden" name="uids[]"
value="<?= (int) $data['UID'] ?>">
<div class="icon-button-container">
<?= $icon_helper->renderFormButton('submit',
'delete', true,
'mail-message-delete-button') ?>
</div>
</form>
</span><?php
} ?>
</div><?php
} ?>
</div>
<?php
if (!empty($data['IMAP_ERROR'])) {
?><p class="red"><?=
$data['IMAP_ERROR'] ?></p>
</div><?php
return;
}
$mime = $data['MIME_MESSAGE'] ?? null;
$message_view = $data['MESSAGE_VIEW'] ?? [];
if ($mime === null) {
?>
<pre class="mail-message-body wordwrap"><?=
$message_view['BODY_RAW'] ??
($data['MESSAGE_BODY'] ?? '')
?></pre>
</div>
<?php
return;
}
$headers = $message_view['HEADERS'] ?? [];
$row = function ($label, $value) {
if ($value === '' || $value === null) {
return;
}
?>
<div class="mail-message-header-row"
data-mail-pretty-only>
<span class="mail-message-header-label"><?=
$label ?></span>
<span class="mail-message-header-value"><?=
$value ?></span>
</div>
<?php
};
$sanitized_html = ($mime->body_html !== '') ?
HtmlSanitizer::sanitize($mime->body_html, true) : '';
$has_blocked_images = (strpos($sanitized_html,
'mail-blocked-image') !== false);
?>
<div class="mail-message-headers">
<button type="button" class="mail-message-toggle-raw"
aria-pressed="false"
title="<?= tl('mail_element_toggle_raw_title')
?>"><?= tl('mail_element_toggle_raw') ?></button>
<?php
if (!empty($data['DKIM_STATUS'])) {
$dkim_status = $data['DKIM_STATUS'];
$dkim_class = 'mail-dkim-' . $dkim_status;
if ($dkim_status === 'pass') {
$dkim_title = tl('mail_element_dkim_pass');
$dkim_summary =
tl('mail_element_dkim_summary_pass');
} else if ($dkim_status === 'fail') {
$dkim_title = tl('mail_element_dkim_fail');
$dkim_summary =
tl('mail_element_dkim_summary_fail');
} else {
$dkim_title = tl('mail_element_dkim_no_key');
$dkim_summary =
tl('mail_element_dkim_summary_no_key');
}
$bh_header = $data['DKIM_BODY_HASH_HEADER'] ?? '';
$bh_computed = $data['DKIM_BODY_HASH_COMPUTED'] ?? '';
$bh_match = !empty($data['DKIM_BODY_HASH_MATCH']);
$signature_ok = !empty($data['DKIM_SIGNATURE_OK']);
$key_found = !empty($data['DKIM_KEY_FOUND']);
$yes = tl('mail_element_dkim_yes');
$no = tl('mail_element_dkim_no');
?>
<span class="mail-dkim-badge-wrap">
<button type="button"
class="mail-dkim-icon <?= $dkim_class ?>"
aria-expanded="false"
title="<?= $dkim_title ?>"><svg
viewBox="0 0 24 24" width="18" height="18"
aria-hidden="true"><path fill="currentColor"
d="M12 1 3 5v6c0 5 3.8 9.4 9 11 5.2-1.6 9-6
9-11V5l-9-4z"/></svg></button>
<div class="mail-dkim-detail none">
<div class="mail-dkim-detail-title"><?=
$dkim_title ?></div>
<table class="mail-dkim-detail-table"><?php
$detail_row = function ($label,
$value) {
if ($value === '' ||
$value === null) {
return;
}
?><tr><th><?= $label
?></th><td><?= $value
?></td></tr><?php
};
$detail_row(
tl('mail_element_dkim_signing_domain'),
$data['DKIM_DOMAIN'] ?? '');
$detail_row(
tl('mail_element_dkim_algorithm'),
$data['DKIM_ALGORITHM'] ?? '');
$detail_row(
tl('mail_element_dkim_lookup'),
$data['DKIM_DNS_NAME'] ?? '');
$detail_row(
tl('mail_element_dkim_key_status'),
$key_found ?
tl('mail_element_dkim_key_found') :
tl('mail_element_dkim_key_missing'));
$detail_row(
tl('mail_element_dkim_headers_signed'),
$data['DKIM_SIGNED_HEADERS'] ?? '');
$detail_row(
tl('mail_element_dkim_body_hash_header'),
$bh_header);
$detail_row(
tl(
'mail_element_dkim_body_hash_computed'),
$bh_computed);
if ($key_found) {
$detail_row(
tl(
'mail_element_dkim_body_hash_match'),
$bh_match ? $yes : $no);
$detail_row(
tl(
'mail_element_dkim_signature_check'),
$signature_ok ? $yes : $no);
}
?>
</table>
<div class="mail-dkim-detail-summary"><?=
$dkim_summary ?></div>
</div>
</span><?php
}
$row(tl('mail_element_field_subject'),
$headers['subject'] ?? '');
$row(tl('mail_element_field_from'),
$headers['from'] ?? '');
$row(tl('mail_element_field_to'),
$headers['to'] ?? '');
$row(tl('mail_element_field_cc'),
$headers['cc'] ?? '');
$row(tl('mail_element_field_date'),
$headers['date'] ?? '');
?>
</div>
<?php
if (!empty($data['SHOW_TRUST_SENDER'])) {
$trust_url = $this->mailUrl($data, [
'arg' => 'trustSender',
'account_id' => $data['ACCOUNT_ID'],
'uid' => $data['UID'],
'folder' => $data['ACTIVE_FOLDER'] ?? 'INBOX']);
?>
<div class="mail-message-notice">
<?= tl('mail_element_trust_sender_blurb') ?>
<a href="<?= $trust_url ?>"><?=
tl('mail_element_trust_sender_link') ?></a>
</div>
<?php
}
if (!empty($data['SHOW_UNTRUST_SENDER'])) {
$untrust_url = $this->mailUrl($data, [
'arg' => 'untrustSender',
'account_id' => $data['ACCOUNT_ID'],
'uid' => $data['UID'],
'folder' => $data['ACTIVE_FOLDER'] ?? 'INBOX']);
?>
<div class="mail-message-notice">
<?= tl('mail_element_untrust_sender_blurb') ?>
<a href="<?= $untrust_url ?>"><?=
tl('mail_element_untrust_sender_link') ?></a>
</div>
<?php
}
if ($has_blocked_images) { ?>
<div class="mail-message-load-images"
data-mail-pretty-only>
<button type="button"
class="mail-message-load-images-button"><?=
tl('mail_element_load_remote_images')
?></button>
</div><?php
} ?>
<div class="mail-message-body mail-message-body-rendered"
data-mail-view="rendered">
<?php
$use_body_parts = count($mime->body_parts) > 1;
foreach ($mime->body_parts as $body_part) {
if ($body_part['kind'] === 'image') {
$use_body_parts = true;
}
}
if ($use_body_parts) {
$this->renderMailBodyParts($mime->body_parts);
} else if ($sanitized_html !== '') {
?>
<div class="mail-message-body-html"><?=
$sanitized_html ?></div>
<?php
} else {
?>
<pre class="mail-message-body-text"><?=
$message_view['BODY_TEXT'] ?? '' ?></pre>
<?php
}
?>
</div>
<pre class="mail-message-body mail-message-body-raw none"
data-mail-view="raw"><?=
$message_view['BODY_RAW'] ??
($data['MESSAGE_BODY'] ?? '')
?></pre>
<?php
if (!empty($mime->attachments)) {
$attach_count = count($mime->attachments);
$download_all_url = $this->mailUrl($data, [
'arg' => 'downloadAllAttachments',
'account_id' => $data['ACCOUNT_ID'],
'uid' => $data['UID'],
'folder' => $data['ACTIVE_FOLDER'] ?? 'INBOX',
]);
?>
<div class="mail-attachment-list" data-mail-pretty-only>
<div class="mail-attachment-list-header">
<h4 class="mail-attachment-heading"><?=
tl('mail_element_attachments_header') ?>
(<?= $attach_count ?>)</h4><?php
if ($attach_count > 1) { ?>
<a class="mail-attachment-download-all"
href="<?= $download_all_url ?>"><?=
tl('mail_element_attachments_download_all')
?></a><?php
} ?>
</div>
<div class="mail-attachment-grid"><?php
foreach ($mime->attachments as $index =>
$attach) {
$download_url = $this->mailUrl($data, [
'arg' => 'downloadAttachment',
'account_id' => $data['ACCOUNT_ID'],
'uid' => $data['UID'],
'folder' => $data['ACTIVE_FOLDER'] ??
'INBOX',
'index' => $index,
]);
$thumb_uri = $this->attachmentThumbDataUri(
$attach);
$attach_view =
$message_view['ATTACHMENTS'][$index] ?? [];
$filename_html =
$attach_view['FILENAME_HTML'] ?? '';
$ext_label = $attach_view['EXT_LABEL'] ?? '';
?>
<a class="mail-attachment-card"
href="<?= $download_url ?>"
download="<?=
$filename_html ?>"
title="<?=
$filename_html ?>">
<div class="mail-attachment-thumb"><?php
if ($thumb_uri !== '') { ?>
<img src="<?= $thumb_uri ?>"
alt=""><?php
} else { ?>
<span
class="mail-attachment-ext-label"
><?=
$ext_label ?></span><?php
} ?>
</div>
<div class="mail-attachment-info">
<div class="mail-attachment-filename"
><?=
$filename_html ?></div>
<div class="mail-attachment-meta"
><?= $this->formatAttachmentSize(
$attach['size']) ?></div>
</div>
</a><?php
} ?>
</div>
</div>
<?php
}
?>
</div>
<?php
}
/**
* Formats an attachment byte size as a short display string
* with a unit (B, KB, MB). The threshold for switching units
* is 1024 bytes per step, so a 1023-byte file displays as
* "1023 B" while a 1025-byte file displays as "1 KB". Sizes
* above one megabyte are shown to one decimal place, which is
* enough precision for picking-an-attachment context without
* looking pedantic.
*
* @param int $bytes the size in bytes
* @return string a short human-friendly display string
*/
protected function formatAttachmentSize($bytes)
{
if ($bytes < 1024) {
return $bytes . " " . tl('mail_element_size_bytes');
}
if ($bytes < 1024 * 1024) {
return intval($bytes / 1024) . " " .
tl('mail_element_size_kb');
}
return number_format($bytes / (1024 * 1024), 1) . " " .
tl('mail_element_size_mb');
}
/**
* Draws the body of a message piece by piece in the order the
* sender arranged it, so an image shows exactly where it sits
* between blocks of text. A text piece is shown as escaped plain
* text, an html piece is cleaned before display the same way the
* single-html path is, and an image piece is drawn from its own
* bytes as a data URL so it needs no extra request.
*
* @param array $body_parts the ordered pieces from
* MimeMessage::parse, each with a "kind" of text, html, or
* image
* @return void the pieces are written straight to the page
*/
protected function renderMailBodyParts($body_parts)
{
foreach ($body_parts as $body_part) {
if ($body_part['kind'] === 'image') {
$data_url = 'data:' . $body_part['mime_type'] .
';base64,' . base64_encode($body_part['content']);
?>
<img class="mail-message-inline-image" alt=""
src="<?= $data_url ?>"><?php
} else if ($body_part['kind'] === 'html') {
?>
<div class="mail-message-body-html"><?=
HtmlSanitizer::sanitize($body_part['html'], true)
?></div><?php
} else {
?>
<pre class="mail-message-body-text"><?=
htmlspecialchars($body_part['text'], ENT_QUOTES,
'UTF-8') ?></pre><?php
}
}
}
/**
* For an image attachment, returns a webp thumbnail as a
* data URI ready to drop into an <img src="..."> attribute;
* for non-image attachments or when thumbnail generation
* fails (corrupt bytes, unsupported codec, GD unable to
* decode), returns the empty string so the caller can fall
* back to a generic icon.
*
* The attachment bytes are already in memory from the MIME
* parse, so generating thumbs inline is just a few extra GD
* calls per image attachment. Embedding the thumb as a data
* URI avoids the extra HTTP round-trips and re-parsing cost
* of a separate thumb endpoint.
*
* @param array $attach attachment record with mime_type
* and content keys (the format MimeMessage::parse
* produces)
* @return string a complete data:image/webp;base64,... URI
* or '' if no thumb could be made
*/
protected function attachmentThumbDataUri($attach)
{
$mime_type = $attach['mime_type'] ?? '';
if (!str_starts_with(strtolower($mime_type), 'image/')) {
return '';
}
$bytes = $attach['content'] ?? '';
if ($bytes === '') {
return '';
}
$captured = '';
set_error_handler(
function ($errno, $errstr) use (&$captured) {
if (strlen($captured) < 256) {
$captured .= $errstr;
}
});
try {
$image = imagecreatefromstring($bytes);
if ($image === false) {
return '';
}
$thumb_bytes = ImageProcessor::createThumb($image,
64, 64);
} finally {
restore_error_handler();
}
if ($thumb_bytes === '' || $thumb_bytes === false) {
return '';
}
return 'data:image/webp;base64,' .
base64_encode($thumb_bytes);
}
}