diff --git a/.github/workflows/docker-publish-releases.yml b/.github/workflows/docker-publish-releases.yml
deleted file mode 100644
index 1dd127d8..00000000
--- a/.github/workflows/docker-publish-releases.yml
+++ /dev/null
@@ -1,117 +0,0 @@
-name: Publish Docker Image on Release
-
-on:
- release:
- types: [published]
-
-env:
- IMAGE_NAME: ghcr.io/${{ github.repository }}
-
-jobs:
- build:
- strategy:
- fail-fast: false
- matrix:
- include:
- - platform: linux/amd64
- runner: ubuntu-latest
- - platform: linux/arm64
- runner: ubuntu-24.04-arm
- runs-on: ${{ matrix.runner }}
- permissions:
- contents: read
- packages: write
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- - name: Log in to GHCR
- uses: docker/login-action@v3
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Extract metadata
- id: meta
- uses: docker/metadata-action@v5
- with:
- images: ${{ env.IMAGE_NAME }}
-
- - name: Build and push by digest
- id: build
- uses: docker/build-push-action@v6
- with:
- context: .
- platforms: ${{ matrix.platform }}
- labels: ${{ steps.meta.outputs.labels }}
- outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
- cache-from: type=gha,scope=${{ matrix.platform }}
- cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
-
- - name: Export digest
- run: |
- mkdir -p /tmp/digests
- digest="${{ steps.build.outputs.digest }}"
- touch "/tmp/digests/${digest#sha256:}"
-
- - name: Upload digest
- uses: actions/upload-artifact@v4
- with:
- name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
- path: /tmp/digests/*
- if-no-files-found: error
- retention-days: 1
-
- merge:
- runs-on: ubuntu-latest
- needs: build
- permissions:
- contents: read
- packages: write
-
- steps:
- - name: Download digests
- uses: actions/download-artifact@v4
- with:
- path: /tmp/digests
- pattern: digests-*
- merge-multiple: true
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- - name: Log in to GHCR
- uses: docker/login-action@v3
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Extract metadata
- id: meta
- uses: docker/metadata-action@v5
- with:
- images: ${{ env.IMAGE_NAME }}
- tags: |
- type=raw,value=latest
- type=semver,pattern=v{{version}}
- type=semver,pattern={{version}}
- type=semver,pattern=v{{major}}.{{minor}}
- type=semver,pattern={{major}}.{{minor}}
- type=semver,pattern=v{{major}}
- type=semver,pattern={{major}}
-
- - name: Create manifest list and push
- working-directory: /tmp/digests
- run: |
- docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
- $(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
-
- - name: Inspect image
- run: |
- docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index 3e17dbed..11a9654d 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -2,9 +2,7 @@ name: Publish Docker Image
on:
push:
- branches:
- - main
- - dev
+ branches: [main]
paths:
- "Dockerfile"
- ".dockerignore"
@@ -19,6 +17,7 @@ on:
- "package.json"
- "package-lock.json"
- ".github/workflows/docker-publish.yml"
+ tags: ["v*.*.*"]
workflow_dispatch:
env:
@@ -115,8 +114,10 @@ jobs:
with:
images: ${{ env.IMAGE_NAME }}
tags: |
- type=raw,value={{branch}}
- type=sha,prefix={{branch}}-
+ type=raw,value=latest,enable={{is_default_branch}}
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=sha,prefix=
- name: Create manifest list and push
working-directory: /tmp/digests
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fa70b9dc..346cda47 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,22 @@
# Changelog
+## 1.4.6 (2026-03-21)
+
+### Features
+
+- **Demo**: Add full demo mode with fixture data for emails, calendars, contacts, files, filters, identities, mailboxes, and vacation responses
+- **Demo**: Implement JMAP client interface abstraction to support demo and live backends
+- **Contacts**: Add no-category filter, drag-and-drop to category, and category combo box in contact form
+- **Email**: Add hover actions for emails with configurable quick-action buttons
+- **Settings**: Implement keyword migration functionality for upgrading legacy email tags
+- **Security**: Enhance S/MIME certificate extraction and add legacy PBE (password-based encryption) support
+- **Tour**: Add interactive guided tour overlay for new user onboarding
+
+### Fixes
+
+- **Settings**: Add missing `showTimeInMonthView` and `showOnMobile` type definitions to settings store
+- **UI**: Adjust padding and size of sidebar buttons for improved layout
+
## 1.4.5 (2026-03-20)
### Features
diff --git a/VERSION b/VERSION
index e516bb9d..c514bd85 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.4.5
+1.4.6
diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx
index 19e41bbc..671b7ffe 100644
--- a/app/[locale]/calendar/page.tsx
+++ b/app/[locale]/calendar/page.tsx
@@ -795,6 +795,7 @@ export default function CalendarPage() {
diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx
index a7f47981..1b8bc214 100644
--- a/app/[locale]/contacts/page.tsx
+++ b/app/[locale]/contacts/page.tsx
@@ -126,9 +126,24 @@ export default function ContactsPage() {
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : [];
+ // Collect all unique keywords across contacts
+ const allKeywords = useMemo(() => {
+ const kws = new Set();
+ for (const contact of individuals) {
+ if (!contact.keywords) continue;
+ for (const [kw, active] of Object.entries(contact.keywords)) {
+ if (active) kws.add(kw);
+ }
+ }
+ return Array.from(kws).sort((a, b) => a.localeCompare(b));
+ }, [individuals]);
+
// Contacts to display based on active category
const displayedContacts = useMemo(() => {
if (activeCategory === "all") return individuals;
+ if (activeCategory === "uncategorized") {
+ return individuals.filter(c => !c.keywords || Object.keys(c.keywords).filter(k => c.keywords![k]).length === 0);
+ }
if ("addressBookId" in activeCategory) {
const bookId = activeCategory.addressBookId;
return individuals.filter(c => {
@@ -146,6 +161,7 @@ export default function ContactsPage() {
// Label for the current category
const categoryLabel = useMemo(() => {
if (activeCategory === "all") return t("tabs.all");
+ if (activeCategory === "uncategorized") return t("no_category");
if ("addressBookId" in activeCategory) {
const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
return book?.name || t("tabs.all");
@@ -182,6 +198,31 @@ export default function ContactsPage() {
}
}, [client, moveContactToAddressBook, t]);
+ const handleDropContactsToCategory = useCallback(async (contactIds: string[], keyword: string) => {
+ if (!client && supportsSync) return;
+ try {
+ for (const contactId of contactIds) {
+ const contact = contacts.find(c => c.id === contactId);
+ if (!contact) continue;
+ const existingKeywords = contact.keywords || {};
+ if (existingKeywords[keyword]) continue; // already has this keyword
+ const updatedKeywords = { ...existingKeywords, [keyword]: true };
+ if (supportsSync && client) {
+ await updateContact(client, contactId, { keywords: updatedKeywords });
+ } else {
+ updateLocalContact(contactId, { keywords: updatedKeywords });
+ }
+ }
+ const msg = contactIds.length === 1
+ ? t("category_added", { name: keyword })
+ : t("category_added_plural", { count: contactIds.length, name: keyword });
+ toast.success(msg);
+ } catch (error) {
+ console.error('Failed to add contacts to category:', error);
+ toast.error(t("toast.error_update"));
+ }
+ }, [client, supportsSync, contacts, updateContact, updateLocalContact, t]);
+
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
return importContacts(
supportsSync && client ? client : null,
@@ -430,7 +471,7 @@ export default function ContactsPage() {
const renderRightPanel = () => {
switch (view) {
case "create":
- return ;
+ return ;
case "edit":
if (!selectedContact) return null;
@@ -438,6 +479,7 @@ export default function ContactsPage() {
@@ -590,6 +632,7 @@ export default function ContactsPage() {
onEditGroup={handleEditGroupFromSidebar}
onDeleteGroup={handleDeleteGroupFromSidebar}
onDropContacts={handleDropContacts}
+ onDropContactsToCategory={handleDropContactsToCategory}
/>
- {children}
+
+ {children}
+
diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx
index c527bbbf..4e7e7b94 100644
--- a/app/[locale]/login/page.tsx
+++ b/app/[locale]/login/page.tsx
@@ -11,7 +11,7 @@ import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow";
import { useConfig } from "@/hooks/use-config";
import { cn } from "@/lib/utils";
-import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield } from "lucide-react";
+import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play } from "lucide-react";
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
@@ -30,9 +30,9 @@ export default function LoginPage() {
const params = useParams();
const searchParams = useSearchParams();
const isAddAccountMode = searchParams.get("mode") === "add-account";
- const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
+ const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
- const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig();
+ const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const [formData, setFormData] = useState({
@@ -54,6 +54,7 @@ export default function LoginPage() {
const [oauthMetadata, setOauthMetadata] = useState(null);
const [oauthDiscoveryDone, setOauthDiscoveryDone] = useState(false);
const [oauthLoading, setOauthLoading] = useState(false);
+ const [demoLoading, setDemoLoading] = useState(false);
const suggestionsRef = useRef(null);
const inputRef = useRef(null);
@@ -206,7 +207,7 @@ export default function LoginPage() {
);
}
- if (!serverUrl) {
+ if (!serverUrl && !demoMode) {
return (
@@ -353,9 +354,167 @@ export default function LoginPage() {
}
};
+ const handleDemoLogin = async () => {
+ setDemoLoading(true);
+ const success = await loginDemo();
+ if (success) {
+ router.push('/');
+ }
+ setDemoLoading(false);
+ };
+
const currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2];
const CurrentThemeIcon = currentThemeOption.icon;
+ // Demo-only mode: show only a large demo login button
+ if (demoMode && !isAddAccountMode) {
+ return (
+
+ {/* Theme toggle */}
+
+
+
+ {showThemeMenu && (
+
+ {THEME_OPTIONS.map((option) => {
+ const Icon = option.icon;
+ const isActive = theme === option.value;
+ return (
+
+ );
+ })}
+
+ )}
+
+
+
+
+ {/* Header with logo */}
+
+
+

+
+
+ {appName}
+
+
+ {t("demo_tagline")}
+
+
+
+ {/* Large demo button */}
+
+ {error && (
+
+
+
+ {t(`error.${error}`) || t("error.generic")}
+
+
+ )}
+
+
+
+
+ {t("demo_no_signup")}
+
+
+
+
+ {/* Footer */}
+
+ {loginCompanyName && (
+
+ {loginCompanyName}
+
+ )}
+ {(loginImprintUrl || loginPrivacyPolicyUrl || loginWebsiteUrl) && (
+
+ )}
+
+ v{APP_VERSION}
+
+
+
+
+ );
+ }
+
return (
{/* Theme toggle - top right, dropdown style */}
@@ -747,6 +906,34 @@ export default function LoginPage() {
)}
+
+ {/* Demo Mode Button */}
+ {demoMode && !isAddAccountMode && (
+
+
+
+ {t("demo_description")}
+
+
+ )}
diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx
index 520fbb56..c0b39e5a 100644
--- a/app/[locale]/page.tsx
+++ b/app/[locale]/page.tsx
@@ -1174,6 +1174,7 @@ export default function Home() {
onChange={(e) => setSearchQuery(e.target.value)}
className={cn("pl-9 h-9", searchQuery && "pr-8")}
data-search-input
+ data-tour="search-input"
/>
{searchQuery && (