Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
8e5272a
build(theme): add Tailwind v4 + FlyonUI build tooling and brand theme…
andris9 Jul 15, 2026
d9ab031
feat(theme): convert the admin shell to Tailwind v4 + FlyonUI
andris9 Jul 15, 2026
eb3fdf1
feat(theme): convert shared partials, replace bootstrap-autocomplete …
andris9 Jul 15, 2026
c4a719c
feat(theme): convert the dashboard page to FlyonUI
andris9 Jul 15, 2026
f54f5bc
feat(theme): convert the accounts pages to FlyonUI
andris9 Jul 15, 2026
6b176fa
feat(theme): convert the gateways pages to FlyonUI
andris9 Jul 15, 2026
a650568
feat(theme): convert the tokens and templates pages to FlyonUI
andris9 Jul 15, 2026
77b4438
feat(theme): convert webhooks pages to Tailwind/FlyonUI
andris9 Jul 15, 2026
93f7c06
feat(theme): convert config core pages to Tailwind/FlyonUI
andris9 Jul 15, 2026
a787584
feat(theme): convert OAuth2 config pages to Tailwind/FlyonUI
andris9 Jul 15, 2026
5b2b687
feat(theme): convert Document Store pages to Tailwind/FlyonUI
andris9 Jul 15, 2026
06a670e
feat(theme): convert internals pages to Tailwind/FlyonUI
andris9 Jul 15, 2026
568de9e
feat(theme): convert auth and public layouts to Tailwind/FlyonUI
andris9 Jul 15, 2026
836e36f
feat(theme): retire the Bootstrap legacy clusters and dead assets
andris9 Jul 16, 2026
965e652
fix(theme): apply the whole-branch review findings
andris9 Jul 16, 2026
2aa6d22
fix(theme): indicate the selected tab in the admin UI tab strips
andris9 Jul 16, 2026
216334d
feat(theme): replace ClipboardJS with a central copy-to-clipboard han…
andris9 Jul 16, 2026
2ea2c11
feat(theme): use HSTogglePassword for the secret reveal buttons
andris9 Jul 16, 2026
eabc578
feat(theme): move the TLS certificate labels to FlyonUI tooltips
andris9 Jul 16, 2026
7669c5c
feat(theme): retire the bootstrap-compat shim and jQuery
andris9 Jul 16, 2026
b48279f
chore(theme): document why the auth-method tabs stay a custom script …
andris9 Jul 16, 2026
bf9a6db
feat(theme): centralize the ACE editor fullscreen toggle
andris9 Jul 16, 2026
7b737b1
feat(theme): centralize the native datalist autocomplete builder
andris9 Jul 16, 2026
4f492f1
fix(theme): repair the ACE editor toolbar chrome
andris9 Jul 16, 2026
ef22689
fix(theme): gate TLS certificate provisioning on a usable service domain
andris9 Jul 16, 2026
e53dd27
feat(theme): restyle the stat cards to the FlyonUI dashboard idiom
andris9 Jul 16, 2026
bdfea13
fix(theme): tooltip stacking, empty-bubble gating and badge rounding
andris9 Jul 16, 2026
d3d1592
fix(theme): load the ACE language-tools extension where pages expect it
andris9 Jul 16, 2026
b365690
fix: bump ee-client/sentry-node/bullmq and refresh generated artifacts
andris9 Jul 16, 2026
858fbcd
feat(theme): sync the embedded message browser with the admin theme
andris9 Jul 16, 2026
ebe5e90
feat: rebuild the open source license listing
andris9 Jul 16, 2026
ddf696c
fix(theme): render the API reference as a light island
andris9 Jul 16, 2026
b860c28
fix(theme): use a real list for the gateway usage notes
andris9 Jul 16, 2026
a805b3b
fix(theme): cap tooltip width so long texts wrap
andris9 Jul 16, 2026
e8f9791
refactor(theme): extract the shared TLS security card
andris9 Jul 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
4 changes: 0 additions & 4 deletions .ncurc.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,6 @@ module.exports = {
// the deprecated, default-off Document Store. Even the 8.19 minor is unsafe to bundle.
'@elastic/elasticsearch',

// v4.x adds vulnerable jquery + bootstrap runtime dependencies; v3.3.7 has none. Used only
// for the generated software-license listing, never executed at runtime.
'startbootstrap-sb-admin-2',

// @asamuzakjp/css-color >=4.1.2 pulls in @csstools/* v4 which are pure ESM and break pkg bundling
// (transitive via @postalsys/email-text-tools -> jsdom -> cssstyle; also pinned in package.json "overrides").
'@asamuzakjp/css-color'
Expand Down
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ EmailEngine is an email sync platform that provides REST API access to email acc
- **Database**: Redis (ioredis) + BullMQ for job queues
- **Email**: ImapFlow (IMAP), Nodemailer (SMTP)
- **OAuth2**: Gmail API, Microsoft Graph, Mail.ru
- **Web UI**: server-rendered Handlebars, styled with Tailwind v4 + FlyonUI (see Admin UI Theme below)

## Admin UI Theme

The web UI is styled with Tailwind v4 + FlyonUI (daisyUI/Preline based). There is no SPA framework - pages are server-rendered Handlebars with small per-page scripts.

- **Build**: `npm run build:css` compiles `static/css/src/app.tailwind.css` (brand themes, safelists, custom components) into the committed artifact `static/css/flyonui.css`. `static/js/flyonui.js` is vendored from node_modules. Both are regenerated by `copy-static-files.sh` and bundled by pkg. tailwindcss/flyonui/iconify are exact-pinned devDependencies only. After editing templates or `app.tailwind.css`, re-run `npm run build:css` and commit the artifact.
- **Brand**: primary indigo `#4C5CC5`, secondary/accent coral `#F8485E` (from the EmailEngine logo sheet), font PT Sans (self-hosted under `static/fonts/pt-sans/`). Light + dark themes with a topbar toggle (`eeTheme` in localStorage, applied pre-paint by `views/partials/theme_init.hbs`).
- **Component library**: reusable parameterized partials in `views/partials/ui/` (btn, badge, modal, form-field, checkbox, tooltip, tooltip-rich, stat-card, page-header, setting-ref, details, breadcrumb, pagination, menu-*). Build missing primitives there first; never hand-roll per page. Block-form partials must neutralize their own hash params when invoking `{{> @partial-block}}` (and any nested `ui/*` call) - Handlebars hash params fall through into nested partials otherwise.
- **Safelist trap**: class names built in JS strings (`badge-${type}`) or emitted from `lib/`/`workers/` (server-driven icons) are invisible to the Tailwind scanner - add them to the `@source inline(...)` safelists in `app.tailwind.css`.
- **Icons**: Tabler via Iconify classes (`icon-[tabler--...]` on a span). Do not add Font Awesome back. Do not add apexcharts (restrictive license, breaks `npm run licenses`).
- **No jQuery**: the admin UI is jQuery-free. The SSE-updated state badges render through `ui/state-badge` (FlyonUI tooltip carries the error text; `static/js/app.js` repaints badge class/text and toggles the tooltip body), and the TLS cert labels use `ui/tooltip` repainted by `paintCertData()` in `static/js/ui.js`. Do not reintroduce jQuery or `data-toggle` attributes.
- **Do not run prettier on .hbs files** (it mangles inline scripts), and never use HTML entities inside Handlebars partial hash strings (they double-escape; entities in plain markup are fine).

## Development Commands

Expand Down
6 changes: 6 additions & 0 deletions copy-static-files.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ cp node_modules/ace-builds/src-min/ext-searchbox.js static/js/ace/ext-searchbox.

cp node_modules/\@postalsys/ee-client/index.js static/js/ee-client.js

# FlyonUI browser bundle (vendored like ace above; committed and bundled by pkg)
cp node_modules/flyonui/flyonui.js static/js/flyonui.js

# Rebuild the compiled admin UI stylesheet (Tailwind v4 + FlyonUI)
npm run build:css

wget https://developers.google.com/static/crawling/ipranges/special-crawlers.json -O data/google-crawlers.json
node -e 'console.log("Google crawlers updated: "+require("./data/google-crawlers.json").creationTime);'

Expand Down
2 changes: 1 addition & 1 deletion data/google-crawlers.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"creationTime": "2026-07-10T14:46:30.000000",
"creationTime": "2026-07-15T14:46:20.000000",
"prefixes": [
{
"ipv6Prefix": "2001:4860:4801:2008::/64"
Expand Down
10 changes: 5 additions & 5 deletions lib/oauth2-apps.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ function oauth2ProviderData(provider, selector) {

switch (provider) {
case 'gmail':
providerData.icon = 'fab fa-google';
providerData.icon = 'icon-[tabler--brand-google]';
providerData.tutorialUrl = 'https://emailengine.app/gmail-over-imap';
providerData.linkImage = '/static/providers/google_dark_edited.png';
providerData.imap = {
Expand All @@ -90,7 +90,7 @@ function oauth2ProviderData(provider, selector) {
break;

case 'gmailService':
providerData.icon = 'fab fa-google';
providerData.icon = 'icon-[tabler--brand-google]';
providerData.tutorialUrl = 'https://emailengine.app/google-service-accounts';
providerData.imap = {
host: 'imap.gmail.com',
Expand Down Expand Up @@ -125,7 +125,7 @@ function oauth2ProviderData(provider, selector) {
break;
}

providerData.icon = 'fab fa-microsoft';
providerData.icon = 'icon-[tabler--brand-windows]';
if (provider === 'outlook') {
providerData.tutorialUrl = 'https://emailengine.app/outlook-and-ms-365';
providerData.linkImage = '/static/providers/ms_light.svg';
Expand All @@ -146,7 +146,7 @@ function oauth2ProviderData(provider, selector) {
break;

case 'mailRu':
providerData.icon = 'fa fa-envelope';
providerData.icon = 'icon-[tabler--mail]';
providerData.imap = {
host: 'imap.mail.ru',
port: 993,
Expand All @@ -160,7 +160,7 @@ function oauth2ProviderData(provider, selector) {
break;

default:
providerData.icon = 'fa fa-envelope';
providerData.icon = 'icon-[tabler--mail]';
}

return providerData;
Expand Down
6 changes: 6 additions & 0 deletions lib/ui-routes/account-routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -1202,6 +1202,12 @@ function init(args) {
let gatewayObject = new Gateway({ redis });
let gateways = await gatewayObject.listGateways(0, 100);

// the ID response fields come from the remote IMAP server; only expose
// support-url as a link when it is a plain http(s) URL
if (accountData.imapServerInfo && accountData.imapServerInfo['support-url'] && !/^https?:\/\//i.test(accountData.imapServerInfo['support-url'])) {
delete accountData.imapServerInfo['support-url'];
}

let capabilities = [];
if (accountData.imapServerInfo && accountData.imapServerInfo.capabilities) {
capabilities = await capa(accountData.imapServerInfo.capabilities);
Expand Down
4 changes: 2 additions & 2 deletions lib/ui-routes/admin-entities-routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -1575,7 +1575,7 @@ return payload;`)
label.type = 'success';
label.name = 'Connected';
} else if (entry.lastError) {
label.type = 'danger';
label.type = 'error';
label.name = 'Error';
label.error = entry.lastError.response;
} else {
Expand Down Expand Up @@ -1645,7 +1645,7 @@ return payload;`)
label.type = 'success';
label.name = 'Connected';
} else if (gatewayData.lastError) {
label.type = 'danger';
label.type = 'error';
label.name = 'Error';
label.error = gatewayData.lastError.response;
} else {
Expand Down
19 changes: 11 additions & 8 deletions lib/ui-routes/dashboard-routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,49 +23,52 @@ function init(args) {
key: 'events:messageNew',
title: 'New emails',
color: 'primary',
icon: 'envelope',
// icon must be a full iconify class; this file is not scanned by
// Tailwind - add any new class to the @source inline() safelist
// in static/css/src/app.tailwind.css or it renders as no icon
icon: 'icon-[tabler--mail]',
comment: 'Detected new emails in IMAP mailboxes.'
},
{
key: 'webhooks:success',
title: 'Webhooks sent',
color: 'primary',
icon: 'network-wired',
icon: 'icon-[tabler--webhook]',
comment: 'Count of successfully delivered webhooks.'
},
{
key: 'webhooks:fail',
title: 'Webhooks failed',
color: 'danger',
icon: 'network-wired',
icon: 'icon-[tabler--webhook]',
comment: 'Count of webhooks that failed to deliver.'
},
{
key: 'submit:success',
title: 'Emails sent',
color: 'primary',
icon: 'mail-bulk',
icon: 'icon-[tabler--mail-forward]',
comment: 'Count of emails sent to MTA servers.'
},
{
key: 'submit:fail',
title: 'Emails rejected',
color: 'danger',
icon: 'mail-bulk',
icon: 'icon-[tabler--mail-forward]',
comment: 'Count of emails rejected by MTA servers.'
},
{
key: 'apiCall:success',
title: 'Successful API calls',
color: 'primary',
icon: 'file-code',
icon: 'icon-[tabler--file-code]',
comment: 'Successful API calls with positive responses.'
},
{
key: 'apiCall:fail',
title: 'Failed API calls',
color: 'danger',
icon: 'file-code',
icon: 'icon-[tabler--file-code]',
comment: 'API calls that returned error responses.'
}
];
Expand Down Expand Up @@ -106,7 +109,7 @@ function init(args) {
key: 'redisPing',
title: 'Redis Latency',
color: typeof stats.redisPing !== 'number' ? 'warning' : stats.redisPing < ALLOWED_REDIS_LATENCY ? 'success' : 'danger',
icon: 'clock',
icon: 'icon-[tabler--clock]',
comment: 'How many milliseconds does it take to run a Redis command',
value: typeof stats.redisPing !== 'number' ? '\u2013' : nrFormatter.format(stats.redisPing / 1000000)
}
Expand Down
6 changes: 3 additions & 3 deletions lib/ui-routes/internals-routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,11 @@ function init(args) {
case 'critical':
case 'restarting':
threadInfo.healthBadge = 'Critical';
threadInfo.healthBadgeType = 'danger';
threadInfo.healthBadgeType = 'error';
break;
case 'unknown':
threadInfo.healthBadge = 'Unknown';
threadInfo.healthBadgeType = 'secondary';
threadInfo.healthBadgeType = 'neutral';
break;
// healthy - no badge shown to avoid clutter
}
Expand Down Expand Up @@ -266,7 +266,7 @@ function init(args) {
name: accountId,
email: '',
type: { name: 'Unknown' },
stateLabel: { type: 'secondary', name: 'Unknown' }
stateLabel: { type: 'neutral', name: 'Unknown' }
});
}
}
Expand Down
25 changes: 14 additions & 11 deletions lib/ui-routes/route-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,21 +69,24 @@ function formatAccountData(account, gt) {
} else if (account.oauth2 && account.oauth2.provider) {
account.type = oauth2ProviderData(account.oauth2.provider);
} else if (account.imap && !account.imap.disabled) {
account.type.icon = 'fa fa-envelope-square';
account.type.icon = 'icon-[tabler--mail]';
account.type.name = 'IMAP';
account.type.comment = psl.get(account.imap.host) || account.imap.host;
} else if (account.smtp) {
account.type.icon = 'fa fa-paper-plane';
account.type.icon = 'icon-[tabler--send]';
account.type.name = 'SMTP';
account.type.comment = psl.get(account.smtp.host) || account.smtp.host;
} else if (account.oauth2 && account.oauth2.auth && account.oauth2.auth.delegatedAccount) {
account.type.icon = 'fa fa-arrow-alt-circle-right';
account.type.icon = 'icon-[tabler--arrow-right-circle]';
account.type.name = gt.gettext('Delegated');
account.type.comment = util.format(gt.gettext('Using credentials from "%s"'), account.oauth2.auth.delegatedAccount);
} else {
account.type.name = 'N/A';
}

// composed hover label for the type icon (used by ui/tooltip in the views)
account.type.label = `${account.type.name || ''}${account.type.comment ? ` (${account.type.comment})` : ''}`;

switch (account.state) {
case 'init':
account.stateLabel = {
Expand Down Expand Up @@ -117,7 +120,7 @@ function formatAccountData(account, gt) {

case 'disabled':
account.stateLabel = {
type: 'secondary',
type: 'neutral',
name: 'Disabled',
error: account.disabledReason
};
Expand Down Expand Up @@ -148,15 +151,15 @@ function formatAccountData(account, gt) {
}

account.stateLabel = {
type: 'danger',
type: 'error',
name: 'Failed',
error: errorMessage
};
break;
}
case 'unset':
account.stateLabel = {
type: 'light',
type: 'neutral',
name: 'Not syncing'
};
break;
Expand All @@ -168,13 +171,13 @@ function formatAccountData(account, gt) {
break;
case 'paused':
account.stateLabel = {
type: 'secondary',
type: 'neutral',
name: 'Paused'
};
break;
default:
account.stateLabel = {
type: 'secondary',
type: 'neutral',
name: 'N/A'
};
break;
Expand All @@ -183,7 +186,7 @@ function formatAccountData(account, gt) {
// Check if IMAP was disabled due to errors - override state label to show error
if (account.imap && account.imap.disabled && account.lastErrorState) {
account.stateLabel = {
type: 'danger',
type: 'error',
name: 'Failed',
error: account.lastErrorState.description || account.lastErrorState.response
};
Expand Down Expand Up @@ -247,14 +250,14 @@ function formatServerState(state, payload) {

case 'failed':
return {
type: 'danger',
type: 'error',
name: state,
error: (payload && payload.error && payload.error.message) || null
};

default:
return {
type: 'secondary',
type: 'neutral',
name: 'N/A'
};
}
Expand Down
Loading
Loading