diff --git a/CHANGELOG.md b/CHANGELOG.md
index f3a38605..ca37314d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,22 @@
# Changelog
+## 1.4.8 (2026-03-23)
+
+### Features
+
+- **Email**: Add support for marking emails as answered or forwarded and display status icons in email list and thread views
+- **Email**: Enhance identity selection by supporting sub-addressing (plus addressing) in email composer
+- **Settings**: Add notification settings with sound picker, preview playback, and configurable alert sounds
+- **Settings**: Add default mail program settings with localization support across all locales
+- **Auth**: Implement path prefix handling for OAuth callbacks and login redirects, enabling reverse proxy deployments
+- **Validation**: Add all multi-part TLDs for domain validation in favicon API (#81)
+
+### Fixes
+
+- **Calendar**: Fix bugs in duration parsing, RFC compliance, and event handling across calendar components
+- **Calendar**: Detect tasks created by external CalDAV clients such as Thunderbird
+- **Settings**: Enhance account settings with username and authentication method display (#90)
+
## 1.4.7 (2026-03-21)
### Features
diff --git a/VERSION b/VERSION
index be05bba9..b2e46d18 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.4.7
+1.4.8
diff --git a/app/[locale]/auth/callback/page.tsx b/app/[locale]/auth/callback/page.tsx
index bb8c2896..e5c48f22 100644
--- a/app/[locale]/auth/callback/page.tsx
+++ b/app/[locale]/auth/callback/page.tsx
@@ -4,6 +4,7 @@ import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useAuthStore } from "@/stores/auth-store";
+import { getPathPrefix } from "@/lib/browser-navigation";
import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useParams } from "next/navigation";
@@ -48,7 +49,8 @@ function OAuthCallbackInner() {
return;
}
- const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
+ const prefix = getPathPrefix(params.locale as string);
+ const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri)
.then((success) => {
@@ -57,7 +59,7 @@ function OAuthCallbackInner() {
sessionStorage.removeItem("oauth_code_verifier");
sessionStorage.removeItem("oauth_server_url");
sessionStorage.removeItem("oauth_add_account_mode");
- let redirectTo = `/${params.locale}`;
+ let redirectTo = `${prefix}/${params.locale}`;
try {
const saved = sessionStorage.getItem('redirect_after_login');
if (saved) {
@@ -75,10 +77,11 @@ function OAuthCallbackInner() {
});
} else if (state) {
// Server-side SSO flow — state was stored in encrypted httpOnly cookie
+ const ssoPrefix = getPathPrefix(params.locale as string);
loginWithServerSso(code, state)
.then((success) => {
if (success) {
- let redirectTo = `/${params.locale}`;
+ let redirectTo = `${ssoPrefix}/${params.locale}`;
try {
const saved = sessionStorage.getItem('redirect_after_login');
if (saved) {
@@ -114,7 +117,7 @@ function OAuthCallbackInner() {
diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx
index 0f5852ff..def07bca 100644
--- a/app/[locale]/page.tsx
+++ b/app/[locale]/page.tsx
@@ -407,7 +407,10 @@ export default function Home() {
// Handle new email notifications - play sound
useEffect(() => {
if (newEmailNotification) {
- playNotificationSound();
+ const { emailNotificationsEnabled, emailNotificationSound, notificationSoundChoice } = useSettingsStore.getState();
+ if (emailNotificationsEnabled && emailNotificationSound) {
+ playNotificationSound(notificationSoundChoice);
+ }
debug.log('New email received:', newEmailNotification.subject);
clearNewEmailNotification();
}
@@ -441,9 +444,27 @@ export default function Home() {
if (!client) return;
try {
+ const effectiveMode = pendingDraft?.mode ?? composerMode;
+ const originalEmailId = selectedEmail?.id;
+
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments);
setShowComposer(false);
+ // Mark the original email with $answered or $forwarded keyword
+ if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) {
+ try {
+ await client.setKeyword(originalEmailId, '$answered');
+ } catch (e) {
+ debug.error('Failed to set $answered keyword:', e);
+ }
+ } else if (originalEmailId && effectiveMode === 'forward') {
+ try {
+ await client.setKeyword(originalEmailId, '$forwarded');
+ } catch (e) {
+ debug.error('Failed to set $forwarded keyword:', e);
+ }
+ }
+
// Refresh the current mailbox to update the UI
await fetchEmails(client, selectedMailbox);
} catch (error) {
@@ -858,6 +879,8 @@ export default function Home() {
// Append signature from the primary identity
const finalBody = appendPlainTextSignature(body, primaryIdentity);
+ const originalEmailId = selectedEmail.id;
+
// Send reply with just the body text
await sendEmail(
client,
@@ -872,6 +895,13 @@ export default function Home() {
primaryIdentity?.name || undefined
);
+ // Mark the original email as answered
+ try {
+ await client.setKeyword(originalEmailId, '$answered');
+ } catch (e) {
+ debug.error('Failed to set $answered keyword:', e);
+ }
+
// Refresh emails to show the sent reply
await fetchEmails(client, selectedMailbox);
};
diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx
index 8ccd3179..d9d81a80 100644
--- a/app/[locale]/settings/page.tsx
+++ b/app/[locale]/settings/page.tsx
@@ -24,6 +24,7 @@ import {
BookUser,
KeyRound,
PanelLeftClose,
+ Bell,
type LucideIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -44,6 +45,7 @@ import { FilesSettingsComponent } from '@/components/settings/files-settings';
import { ContactsSettings } from '@/components/settings/contacts-settings';
import { SmimeSettings } from '@/components/settings/smime-settings';
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
+import { NotificationSettings } from '@/components/settings/notification-settings';
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useIsDesktop } from '@/hooks/use-media-query';
@@ -55,7 +57,7 @@ import { ResizeHandle } from '@/components/layout/resize-handle';
import { useConfig } from '@/hooks/use-config';
import { cn } from '@/lib/utils';
-type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'advanced';
+type Tab = 'appearance' | 'email' | 'notifications' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'advanced';
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
interface TabDef {
@@ -68,6 +70,7 @@ interface TabDef {
const tabIcons: Record = {
appearance: Palette,
email: Mail,
+ notifications: Bell,
account: User,
security: Shield,
identities: UserPen,
@@ -138,6 +141,7 @@ export default function SettingsPage() {
const tabs: TabDef[] = [
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' },
{ id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: 'general' },
+ { id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' },
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' },
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []),
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' },
@@ -177,6 +181,7 @@ export default function SettingsPage() {
<>
{activeTab === 'appearance' && }
{activeTab === 'email' && }
+ {activeTab === 'notifications' && }
{activeTab === 'account' && }
{activeTab === 'security' && }
{activeTab === 'identities' && }
diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts
index a3e94e69..f003957e 100644
--- a/app/api/favicon/route.ts
+++ b/app/api/favicon/route.ts
@@ -40,32 +40,358 @@ function isValidDomain(domain: string): boolean {
// Known multi-part TLDs where the registrable domain includes one extra label.
const MULTI_PART_TLDS = new Set([
- "co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk", "net.uk",
- "co.jp", "or.jp", "ne.jp", "ac.jp", "go.jp",
- "co.kr", "or.kr", "go.kr", "ac.kr",
- "co.in", "net.in", "org.in", "ac.in", "gov.in",
- "co.nz", "org.nz", "net.nz", "govt.nz", "ac.nz",
- "co.za", "org.za", "net.za", "gov.za", "ac.za",
- "com.au", "net.au", "org.au", "edu.au", "gov.au",
- "com.br", "net.br", "org.br", "edu.br", "gov.br",
- "com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn",
- "com.mx", "net.mx", "org.mx", "gob.mx", "edu.mx",
- "com.ar", "net.ar", "org.ar", "gob.ar", "edu.ar",
- "com.tw", "net.tw", "org.tw", "edu.tw", "gov.tw",
- "com.hk", "net.hk", "org.hk", "edu.hk", "gov.hk",
- "com.sg", "net.sg", "org.sg", "edu.sg", "gov.sg",
- "com.my", "net.my", "org.my", "edu.my", "gov.my",
- "com.ph", "net.ph", "org.ph", "edu.ph", "gov.ph",
- "com.pk", "net.pk", "org.pk", "edu.pk", "gov.pk",
- "com.ng", "net.ng", "org.ng", "edu.ng", "gov.ng",
- "co.il", "org.il", "net.il", "ac.il", "gov.il",
- "co.th", "or.th", "ac.th", "go.th", "in.th",
- "co.id", "or.id", "ac.id", "go.id", "web.id",
- "com.tr", "net.tr", "org.tr", "edu.tr", "gov.tr",
- "com.ua", "net.ua", "org.ua", "edu.ua", "gov.ua",
- "com.eg", "net.eg", "org.eg", "edu.eg", "gov.eg",
- "com.sa", "net.sa", "org.sa", "edu.sa", "gov.sa",
- "co.ke", "or.ke", "ac.ke", "go.ke", "ne.ke",
+ // .ac
+ "com.ac", "gov.ac", "mil.ac", "net.ac", "org.ac",
+ // .ae
+ "ac.ae", "co.ae", "gov.ae", "mil.ae", "name.ae", "net.ae", "org.ae", "pro.ae", "sch.ae",
+ // .af
+ "com.af", "edu.af", "gov.af", "net.af", "org.af",
+ // .al
+ "com.al", "edu.al", "gov.al", "mil.al", "net.al", "org.al",
+ // .ao
+ "co.ao", "ed.ao", "gv.ao", "it.ao", "og.ao", "pb.ao",
+ // .ar
+ "com.ar", "edu.ar", "gob.ar", "gov.ar", "int.ar", "mil.ar", "net.ar", "org.ar", "tur.ar",
+ // .at
+ "ac.at", "co.at", "gv.at", "or.at",
+ // .au
+ "asn.au", "com.au", "csiro.au", "edu.au", "gov.au", "id.au", "net.au", "org.au",
+ // .ba
+ "co.ba", "com.ba", "edu.ba", "gov.ba", "mil.ba", "net.ba", "org.ba", "rs.ba",
+ "unbi.ba", "unmo.ba", "unsa.ba", "untz.ba", "unze.ba",
+ // .bb
+ "biz.bb", "co.bb", "com.bb", "edu.bb", "gov.bb", "info.bb", "net.bb", "org.bb",
+ "store.bb", "tv.bb",
+ // .bh
+ "biz.bh", "cc.bh", "com.bh", "edu.bh", "gov.bh", "info.bh", "net.bh", "org.bh",
+ // .bn
+ "com.bn", "edu.bn", "gov.bn", "net.bn", "org.bn",
+ // .bo
+ "com.bo", "edu.bo", "gob.bo", "gov.bo", "int.bo", "mil.bo", "net.bo", "org.bo", "tv.bo",
+ // .br
+ "adm.br", "adv.br", "agr.br", "am.br", "arq.br", "art.br", "ato.br", "b.br",
+ "bio.br", "blog.br", "bmd.br", "cim.br", "cng.br", "cnt.br", "com.br", "coop.br",
+ "ecn.br", "edu.br", "eng.br", "esp.br", "etc.br", "eti.br", "far.br", "flog.br",
+ "fm.br", "fnd.br", "fot.br", "fst.br", "g12.br", "ggf.br", "gov.br", "imb.br",
+ "ind.br", "inf.br", "jor.br", "jus.br", "lel.br", "mat.br", "med.br", "mil.br",
+ "mus.br", "net.br", "nom.br", "not.br", "ntr.br", "odo.br", "org.br", "ppg.br",
+ "pro.br", "psc.br", "psi.br", "qsl.br", "rec.br", "slg.br", "srv.br", "tmp.br",
+ "trd.br", "tur.br", "tv.br", "vet.br", "vlog.br", "wiki.br", "zlg.br",
+ // .bs
+ "com.bs", "edu.bs", "gov.bs", "net.bs", "org.bs",
+ // .bz
+ "com.bz", "edu.bz", "gov.bz", "net.bz", "org.bz",
+ // .ca
+ "ab.ca", "bc.ca", "mb.ca", "nb.ca", "nf.ca", "nl.ca", "ns.ca", "nt.ca",
+ "nu.ca", "on.ca", "pe.ca", "qc.ca", "sk.ca", "yk.ca",
+ // .ck
+ "biz.ck", "co.ck", "edu.ck", "gen.ck", "gov.ck", "info.ck", "net.ck", "org.ck",
+ // .cn
+ "ac.cn", "ah.cn", "bj.cn", "com.cn", "cq.cn", "edu.cn", "fj.cn", "gd.cn",
+ "gov.cn", "gs.cn", "gx.cn", "gz.cn", "ha.cn", "hb.cn", "he.cn", "hi.cn",
+ "hl.cn", "hn.cn", "jl.cn", "js.cn", "jx.cn", "ln.cn", "mil.cn", "net.cn",
+ "nm.cn", "nx.cn", "org.cn", "qh.cn", "sc.cn", "sd.cn", "sh.cn", "sn.cn",
+ "sx.cn", "tj.cn", "tw.cn", "xj.cn", "xz.cn", "yn.cn", "zj.cn",
+ // .co
+ "com.co", "edu.co", "gov.co", "mil.co", "net.co", "nom.co", "org.co",
+ // .cr
+ "ac.cr", "co.cr", "ed.cr", "fi.cr", "go.cr", "or.cr", "sa.cr",
+ // .cy
+ "ac.cy", "biz.cy", "com.cy", "ekloges.cy", "gov.cy", "ltd.cy", "name.cy",
+ "net.cy", "org.cy", "parliament.cy", "press.cy", "pro.cy", "tm.cy",
+ // .do
+ "art.do", "com.do", "edu.do", "gob.do", "gov.do", "mil.do", "net.do", "org.do",
+ "sld.do", "web.do",
+ // .dz
+ "art.dz", "asso.dz", "com.dz", "edu.dz", "gov.dz", "net.dz", "org.dz", "pol.dz",
+ // .ec
+ "com.ec", "edu.ec", "fin.ec", "gov.ec", "info.ec", "med.ec", "mil.ec", "net.ec",
+ "org.ec", "pro.ec",
+ // .eg
+ "com.eg", "edu.eg", "eun.eg", "gov.eg", "mil.eg", "name.eg", "net.eg", "org.eg", "sci.eg",
+ // .er
+ "com.er", "edu.er", "gov.er", "ind.er", "mil.er", "net.er", "org.er", "rochest.er", "w.er",
+ // .es
+ "com.es", "edu.es", "gob.es", "nom.es", "org.es",
+ // .et
+ "biz.et", "com.et", "edu.et", "gov.et", "info.et", "name.et", "net.et", "org.et",
+ // .fj
+ "ac.fj", "biz.fj", "com.fj", "info.fj", "mil.fj", "name.fj", "net.fj", "org.fj", "pro.fj",
+ // .fk
+ "ac.fk", "co.fk", "gov.fk", "net.fk", "nom.fk", "org.fk",
+ // .fr
+ "asso.fr", "com.fr", "gouv.fr", "nom.fr", "prd.fr", "presse.fr", "tm.fr",
+ // .gg
+ "co.gg", "net.gg", "org.gg",
+ // .gh
+ "com.gh", "edu.gh", "gov.gh", "mil.gh", "org.gh",
+ // .gn
+ "ac.gn", "com.gn", "gov.gn", "net.gn", "org.gn",
+ // .gr
+ "com.gr", "edu.gr", "gov.gr", "mil.gr", "net.gr", "org.gr",
+ // .gt
+ "com.gt", "edu.gt", "gob.gt", "ind.gt", "mil.gt", "net.gt", "org.gt",
+ // .gu
+ "com.gu", "edu.gu", "gov.gu", "net.gu", "org.gu",
+ // .hk
+ "com.hk", "edu.hk", "gov.hk", "idv.hk", "net.hk", "org.hk",
+ // .id
+ "ac.id", "co.id", "go.id", "mil.id", "net.id", "or.id", "sch.id", "web.id",
+ // .il
+ "ac.il", "co.il", "gov.il", "idf.il", "k12.il", "muni.il", "net.il", "org.il",
+ // .in
+ "4fd.in", "ac.in", "co.in", "edu.in", "ernet.in", "firm.in", "gen.in", "gov.in",
+ "ind.in", "mil.in", "net.in", "nic.in", "org.in", "res.in",
+ // .iq
+ "com.iq", "edu.iq", "gov.iq", "mil.iq", "net.iq", "org.iq",
+ // .ir
+ "ac.ir", "co.ir", "dnssec.ir", "gov.ir", "id.ir", "net.ir", "org.ir", "sch.ir",
+ // .it
+ "edu.it", "gov.it",
+ // .je
+ "co.je", "net.je", "org.je",
+ // .jo
+ "com.jo", "edu.jo", "gov.jo", "mil.jo", "name.jo", "net.jo", "org.jo", "sch.jo",
+ // .jp
+ "ac.jp", "ad.jp", "co.jp", "ed.jp", "go.jp", "gr.jp", "lg.jp", "ne.jp", "or.jp",
+ // .ke
+ "ac.ke", "co.ke", "go.ke", "info.ke", "me.ke", "mobi.ke", "ne.ke", "or.ke", "sc.ke",
+ // .kh
+ "com.kh", "edu.kh", "gov.kh", "mil.kh", "net.kh", "org.kh", "per.kh",
+ // .ki
+ "biz.ki", "com.ki", "de.ki", "edu.ki", "gov.ki", "info.ki", "mob.ki", "net.ki",
+ "org.ki", "tel.ki",
+ // .km
+ "asso.km", "com.km", "coop.km", "edu.km", "gouv.km", "medecin.km", "mil.km",
+ "nom.km", "notaires.km", "pharmaciens.km", "presse.km", "tm.km", "veterinaire.km",
+ // .kn
+ "edu.kn", "gov.kn", "net.kn", "org.kn",
+ // .kr
+ "ac.kr", "busan.kr", "chungbuk.kr", "chungnam.kr", "co.kr", "daegu.kr",
+ "daejeon.kr", "es.kr", "gangwon.kr", "go.kr", "gwangju.kr", "gyeongbuk.kr",
+ "gyeonggi.kr", "gyeongnam.kr", "hs.kr", "incheon.kr", "jeju.kr", "jeonbuk.kr",
+ "jeonnam.kr", "kg.kr", "mil.kr", "ms.kr", "ne.kr", "or.kr", "pe.kr", "re.kr",
+ "sc.kr", "seoul.kr", "ulsan.kr",
+ // .kw
+ "com.kw", "edu.kw", "gov.kw", "net.kw", "org.kw",
+ // .ky
+ "com.ky", "edu.ky", "gov.ky", "net.ky", "org.ky",
+ // .kz
+ "com.kz", "edu.kz", "gov.kz", "mil.kz", "net.kz", "org.kz",
+ // .lb
+ "com.lb", "edu.lb", "gov.lb", "net.lb", "org.lb",
+ // .lk
+ "assn.lk", "com.lk", "edu.lk", "gov.lk", "grp.lk", "hotel.lk", "int.lk", "ltd.lk",
+ "net.lk", "ngo.lk", "org.lk", "sch.lk", "soc.lk", "web.lk",
+ // .lr
+ "com.lr", "edu.lr", "gov.lr", "net.lr", "org.lr",
+ // .lv
+ "asn.lv", "com.lv", "conf.lv", "edu.lv", "gov.lv", "id.lv", "mil.lv", "net.lv", "org.lv",
+ // .ly
+ "com.ly", "edu.ly", "gov.ly", "id.ly", "med.ly", "net.ly", "org.ly", "plc.ly", "sch.ly",
+ // .ma
+ "ac.ma", "co.ma", "gov.ma", "net.ma", "org.ma", "press.ma",
+ // .mc
+ "asso.mc", "tm.mc",
+ // .me
+ "ac.me", "co.me", "edu.me", "gov.me", "its.me", "net.me", "org.me", "priv.me",
+ // .mg
+ "com.mg", "edu.mg", "gov.mg", "mil.mg", "nom.mg", "org.mg", "prd.mg", "tm.mg",
+ // .mk
+ "com.mk", "edu.mk", "gov.mk", "inf.mk", "name.mk", "net.mk", "org.mk", "pro.mk",
+ // .ml
+ "com.ml", "edu.ml", "gov.ml", "net.ml", "org.ml", "presse.ml",
+ // .mn
+ "edu.mn", "gov.mn", "org.mn",
+ // .mo
+ "com.mo", "edu.mo", "gov.mo", "net.mo", "org.mo",
+ // .mt
+ "com.mt", "edu.mt", "gov.mt", "net.mt", "org.mt",
+ // .mu
+ "ac.mu", "co.mu", "com.mu", "gov.mu", "net.mu", "or.mu", "org.mu",
+ // .mv
+ "aero.mv", "biz.mv", "com.mv", "coop.mv", "edu.mv", "gov.mv", "info.mv",
+ "int.mv", "mil.mv", "museum.mv", "name.mv", "net.mv", "org.mv", "pro.mv",
+ // .mw
+ "ac.mw", "co.mw", "com.mw", "coop.mw", "edu.mw", "gov.mw", "int.mw",
+ "museum.mw", "net.mw", "org.mw",
+ // .mx
+ "com.mx", "edu.mx", "gob.mx", "net.mx", "org.mx",
+ // .my
+ "com.my", "edu.my", "gov.my", "mil.my", "name.my", "net.my", "org.my", "sch.my",
+ // .mz
+ "ac.mz", "co.mz", "edu.mz", "gov.mz", "org.mz",
+ // .na
+ "co.na", "com.na",
+ // .nf
+ "arts.nf", "com.nf", "firm.nf", "info.nf", "net.nf", "other.nf", "per.nf",
+ "rec.nf", "store.nf", "web.nf",
+ // .ng
+ "biz.ng", "com.ng", "edu.ng", "gov.ng", "mil.ng", "mobi.ng", "name.ng",
+ "net.ng", "org.ng", "sch.ng",
+ // .ni
+ "ac.ni", "co.ni", "com.ni", "edu.ni", "gob.ni", "mil.ni", "net.ni", "nom.ni", "org.ni",
+ // .np
+ "com.np", "edu.np", "gov.np", "mil.np", "net.np", "org.np",
+ // .nr
+ "biz.nr", "com.nr", "edu.nr", "gov.nr", "info.nr", "net.nr", "org.nr",
+ // .nz
+ "ac.nz", "co.nz", "cri.nz", "geek.nz", "gen.nz", "govt.nz", "health.nz",
+ "iwi.nz", "maori.nz", "mil.nz", "net.nz", "org.nz", "parliament.nz", "school.nz",
+ // .om
+ "ac.om", "biz.om", "co.om", "com.om", "edu.om", "gov.om", "med.om", "mil.om",
+ "museum.om", "net.om", "org.om", "pro.om", "sch.om",
+ // .pa
+ "abo.pa", "ac.pa", "com.pa", "edu.pa", "gob.pa", "ing.pa", "med.pa", "net.pa",
+ "nom.pa", "org.pa", "sld.pa",
+ // .pe
+ "com.pe", "edu.pe", "gob.pe", "mil.pe", "net.pe", "nom.pe", "org.pe", "sld.pe",
+ // .ph
+ "com.ph", "edu.ph", "gov.ph", "i.ph", "mil.ph", "net.ph", "ngo.ph", "org.ph",
+ // .pk
+ "biz.pk", "com.pk", "edu.pk", "fam.pk", "gob.pk", "gok.pk", "gon.pk", "gop.pk",
+ "gos.pk", "gov.pk", "net.pk", "org.pk", "web.pk",
+ // .pl
+ "art.pl", "bialystok.pl", "biz.pl", "com.pl", "edu.pl", "gda.pl", "gdansk.pl",
+ "gorzow.pl", "gov.pl", "info.pl", "katowice.pl", "krakow.pl", "lodz.pl",
+ "lublin.pl", "mil.pl", "net.pl", "ngo.pl", "olsztyn.pl", "org.pl", "poznan.pl",
+ "pwr.pl", "radom.pl", "slupsk.pl", "szczecin.pl", "torun.pl", "warszawa.pl",
+ "waw.pl", "wroc.pl", "wroclaw.pl", "zgora.pl",
+ // .pr
+ "ac.pr", "biz.pr", "com.pr", "edu.pr", "est.pr", "gov.pr", "info.pr", "isla.pr",
+ "name.pr", "net.pr", "org.pr", "pro.pr", "prof.pr",
+ // .ps
+ "com.ps", "edu.ps", "gov.ps", "net.ps", "org.ps", "plo.ps", "sec.ps",
+ // .pt
+ "com.pt", "edu.pt", "gov.pt", "int.pt", "net.pt", "nome.pt", "org.pt", "publ.pt",
+ // .pw
+ "belau.pw", "co.pw", "ed.pw", "go.pw", "ne.pw", "or.pw",
+ // .py
+ "com.py", "edu.py", "gov.py", "mil.py", "net.py", "org.py",
+ // .qa
+ "com.qa", "edu.qa", "gov.qa", "mil.qa", "net.qa", "org.qa",
+ // .re
+ "asso.re", "com.re", "nom.re",
+ // .ro
+ "arts.ro", "com.ro", "firm.ro", "info.ro", "nom.ro", "nt.ro", "org.ro",
+ "rec.ro", "store.ro", "tm.ro", "www.ro",
+ // .rs
+ "ac.rs", "co.rs", "edu.rs", "gov.rs", "in.rs", "org.rs",
+ // .ru
+ "ac.ru", "adygeya.ru", "altai.ru", "amur.ru", "arkhangelsk.ru", "astrakhan.ru",
+ "bashkiria.ru", "belgorod.ru", "bir.ru", "bryansk.ru", "buryatia.ru", "cbg.ru",
+ "chel.ru", "chelyabinsk.ru", "chita.ru", "chukotka.ru", "chuvashia.ru", "com.ru",
+ "dagestan.ru", "e-burg.ru", "edu.ru", "gov.ru", "grozny.ru", "int.ru",
+ "irkutsk.ru", "ivanovo.ru", "izhevsk.ru", "jar.ru", "joshkar-ola.ru",
+ "kalmykia.ru", "kaluga.ru", "kamchatka.ru", "karelia.ru", "kazan.ru", "kchr.ru",
+ "kemerovo.ru", "khabarovsk.ru", "khakassia.ru", "khv.ru", "kirov.ru",
+ "koenig.ru", "komi.ru", "kostroma.ru", "kranoyarsk.ru", "kuban.ru", "kurgan.ru",
+ "kursk.ru", "lipetsk.ru", "magadan.ru", "mari.ru", "mari-el.ru", "marine.ru",
+ "mil.ru", "mordovia.ru", "mosreg.ru", "msk.ru", "murmansk.ru", "nalchik.ru",
+ "net.ru", "nnov.ru", "nov.ru", "novosibirsk.ru", "nsk.ru", "omsk.ru",
+ "orenburg.ru", "org.ru", "oryol.ru", "penza.ru", "perm.ru", "pp.ru", "pskov.ru",
+ "ptz.ru", "rnd.ru", "ryazan.ru", "sakhalin.ru", "samara.ru", "saratov.ru",
+ "simbirsk.ru", "smolensk.ru", "spb.ru", "stavropol.ru", "stv.ru", "surgut.ru",
+ "tambov.ru", "tatarstan.ru", "tom.ru", "tomsk.ru", "tsaritsyn.ru", "tsk.ru",
+ "tula.ru", "tuva.ru", "tver.ru", "tyumen.ru", "udm.ru", "udmurtia.ru",
+ "ulan-ude.ru", "vladikavkaz.ru", "vladimir.ru", "vladivostok.ru", "volgograd.ru",
+ "vologda.ru", "voronezh.ru", "vrn.ru", "vyatka.ru", "yakutia.ru", "yamal.ru",
+ "yekaterinburg.ru", "yuzhno-sakhalinsk.ru",
+ // .rw
+ "ac.rw", "co.rw", "com.rw", "edu.rw", "gouv.rw", "gov.rw", "int.rw", "mil.rw", "net.rw",
+ // .sa
+ "com.sa", "edu.sa", "gov.sa", "med.sa", "net.sa", "org.sa", "pub.sa", "sch.sa",
+ // .sb
+ "com.sb", "edu.sb", "gov.sb", "net.sb", "org.sb",
+ // .sc
+ "com.sc", "edu.sc", "gov.sc", "net.sc", "org.sc",
+ // .sd
+ "com.sd", "edu.sd", "gov.sd", "info.sd", "med.sd", "net.sd", "org.sd", "tv.sd",
+ // .se
+ "a.se", "ac.se", "b.se", "bd.se", "c.se", "d.se", "e.se", "f.se", "g.se",
+ "h.se", "i.se", "k.se", "l.se", "m.se", "n.se", "o.se", "org.se", "p.se",
+ "parti.se", "pp.se", "press.se", "r.se", "s.se", "t.se", "tm.se", "u.se",
+ "w.se", "x.se", "y.se", "z.se",
+ // .sg
+ "com.sg", "edu.sg", "gov.sg", "idn.sg", "net.sg", "org.sg", "per.sg",
+ // .sh
+ "co.sh", "com.sh", "edu.sh", "gov.sh", "net.sh", "nom.sh", "org.sh",
+ // .sl
+ "com.sl", "edu.sl", "gov.sl", "net.sl", "org.sl",
+ // .sn
+ "art.sn", "com.sn", "edu.sn", "gouv.sn", "org.sn", "perso.sn", "univ.sn",
+ // .st
+ "co.st", "com.st", "consulado.st", "edu.st", "embaixada.st", "gov.st", "mil.st",
+ "net.st", "org.st", "principe.st", "saotome.st", "store.st",
+ // .sv
+ "com.sv", "edu.sv", "gob.sv", "org.sv", "red.sv",
+ // .sy
+ "com.sy", "edu.sy", "gov.sy", "mil.sy", "net.sy", "news.sy", "org.sy",
+ // .sz
+ "ac.sz", "co.sz", "org.sz",
+ // .th
+ "ac.th", "co.th", "go.th", "in.th", "mi.th", "net.th", "or.th",
+ // .tj
+ "ac.tj", "biz.tj", "co.tj", "com.tj", "edu.tj", "go.tj", "gov.tj", "info.tj",
+ "int.tj", "mil.tj", "name.tj", "net.tj", "nic.tj", "org.tj", "test.tj", "web.tj",
+ // .tn
+ "agrinet.tn", "com.tn", "defense.tn", "edunet.tn", "ens.tn", "fin.tn", "gov.tn",
+ "ind.tn", "info.tn", "intl.tn", "mincom.tn", "nat.tn", "net.tn", "org.tn",
+ "perso.tn", "rnrt.tn", "rns.tn", "rnu.tn", "tourism.tn",
+ // .tr
+ "av.tr", "bbs.tr", "bel.tr", "biz.tr", "com.tr", "dr.tr", "edu.tr", "gen.tr",
+ "gov.tr", "info.tr", "k12.tr", "name.tr", "net.tr", "org.tr", "pol.tr",
+ "tel.tr", "tsk.tr", "tv.tr", "web.tr",
+ // .tt
+ "aero.tt", "biz.tt", "cat.tt", "co.tt", "com.tt", "coop.tt", "edu.tt", "gov.tt",
+ "info.tt", "int.tt", "jobs.tt", "mil.tt", "mobi.tt", "museum.tt", "name.tt",
+ "net.tt", "org.tt", "pro.tt", "tel.tt", "travel.tt",
+ // .tw
+ "club.tw", "com.tw", "ebiz.tw", "edu.tw", "game.tw", "gov.tw", "idv.tw",
+ "mil.tw", "net.tw", "org.tw",
+ // .tz
+ "ac.tz", "co.tz", "go.tz", "ne.tz", "or.tz",
+ // .ua
+ "biz.ua", "cherkassy.ua", "chernigov.ua", "chernovtsy.ua", "ck.ua", "cn.ua",
+ "co.ua", "com.ua", "crimea.ua", "cv.ua", "dn.ua", "dnepropetrovsk.ua",
+ "donetsk.ua", "dp.ua", "edu.ua", "gov.ua", "if.ua", "in.ua",
+ "ivano-frankivsk.ua", "kh.ua", "kharkov.ua", "kherson.ua", "khmelnitskiy.ua",
+ "kiev.ua", "kirovograd.ua", "km.ua", "kr.ua", "ks.ua", "kv.ua", "lg.ua",
+ "lugansk.ua", "lutsk.ua", "lviv.ua", "me.ua", "mk.ua", "net.ua",
+ "nikolaev.ua", "od.ua", "odessa.ua", "org.ua", "pl.ua", "poltava.ua", "pp.ua",
+ "rovno.ua", "rv.ua", "sebastopol.ua", "sumy.ua", "te.ua", "ternopil.ua",
+ "uzhgorod.ua", "vinnica.ua", "vn.ua", "zaporizhzhe.ua", "zhitomir.ua",
+ "zp.ua", "zt.ua",
+ // .ug
+ "ac.ug", "co.ug", "go.ug", "ne.ug", "or.ug", "org.ug", "sc.ug",
+ // .uk
+ "ac.uk", "bl.uk", "british-library.uk", "co.uk", "cym.uk", "gov.uk", "govt.uk",
+ "icnet.uk", "jet.uk", "lea.uk", "ltd.uk", "me.uk", "mil.uk", "mod.uk",
+ "national-library-scotland.uk", "nel.uk", "net.uk", "nhs.uk", "nic.uk",
+ "nls.uk", "org.uk", "orgn.uk", "parliament.uk", "plc.uk", "police.uk",
+ "sch.uk", "scot.uk", "soc.uk",
+ // .us
+ "4fd.us", "dni.us", "fed.us", "isa.us", "kids.us", "nsn.us",
+ // .uy
+ "com.uy", "edu.uy", "gub.uy", "mil.uy", "net.uy", "org.uy",
+ // .ve
+ "co.ve", "com.ve", "edu.ve", "gob.ve", "info.ve", "mil.ve", "net.ve", "org.ve", "web.ve",
+ // .vi
+ "co.vi", "com.vi", "k12.vi", "net.vi", "org.vi",
+ // .vn
+ "ac.vn", "biz.vn", "com.vn", "edu.vn", "gov.vn", "health.vn", "info.vn",
+ "int.vn", "name.vn", "net.vn", "org.vn", "pro.vn",
+ // .ye
+ "co.ye", "com.ye", "gov.ye", "ltd.ye", "me.ye", "net.ye", "org.ye", "plc.ye",
+ // .yu
+ "ac.yu", "co.yu", "edu.yu", "gov.yu", "org.yu",
+ // .za
+ "ac.za", "agric.za", "alt.za", "bourse.za", "city.za", "co.za", "cybernet.za",
+ "db.za", "edu.za", "gov.za", "grondar.za", "iaccess.za", "imt.za", "inca.za",
+ "landesign.za", "law.za", "mil.za", "net.za", "ngo.za", "nis.za", "nom.za",
+ "olivetti.za", "org.za", "pix.za", "school.za", "tm.za", "web.za",
+ // .zm
+ "ac.zm", "co.zm", "com.zm", "edu.zm", "gov.zm", "net.zm", "org.zm", "sch.zm",
]);
function getRootDomain(domain: string): string {
diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx
index d181b8ab..0817e758 100644
--- a/components/calendar/calendar-week-view.tsx
+++ b/components/calendar/calendar-week-view.tsx
@@ -448,7 +448,8 @@ export function CalendarWeekView({
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && (
(() => {
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
- const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
+ let endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
+ if (endMin <= startMin) endMin = 1440;
const durationMin = Math.max(15, endMin - startMin);
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
const color = cal?.color || "hsl(var(--primary))";
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx
index b6a8874d..5af93c9d 100644
--- a/components/calendar/event-modal.tsx
+++ b/components/calendar/event-modal.tsx
@@ -59,10 +59,12 @@ function buildDuration(startDate: Date, endDate: Date): string {
const minutes = totalMinutes % 60;
let dur = "P";
if (days > 0) dur += `${days}D`;
- dur += "T";
- if (hours > 0) dur += `${hours}H`;
- if (minutes > 0) dur += `${minutes}M`;
- if (dur === "PT") dur = "PT0M";
+ if (hours > 0 || minutes > 0) {
+ dur += "T";
+ if (hours > 0) dur += `${hours}H`;
+ if (minutes > 0) dur += `${minutes}M`;
+ }
+ if (dur === "P") dur = "PT0M";
return dur;
}
@@ -83,9 +85,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType = {
+ uid: newUid,
title: event.title,
description: event.description,
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index a463f4ba..c8f865af 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -943,11 +943,16 @@ export function EmailComposer({
onChange={(e) => setSelectedIdentityId(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
>
- {identities.map((identity) => (
-
- ))}
+ {identities.map((identity) => {
+ const displayEmail = subAddressTag
+ ? generateSubAddress(identity.email, subAddressTag)
+ : identity.email;
+ return (
+
+ );
+ })}
) : (
diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx
index 2b69a663..0ddded47 100644
--- a/components/email/email-list-item.tsx
+++ b/components/email/email-list-item.tsx
@@ -6,7 +6,7 @@ import { formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
-import { Paperclip, Star, Circle, CheckSquare, Square, Tag } from "lucide-react";
+import { Paperclip, Star, Circle, CheckSquare, Square, Tag, Reply, Forward } from "lucide-react";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
@@ -41,6 +41,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isImportant = email.keywords?.["$important"];
+ const isAnswered = email.keywords?.$answered;
+ const isForwarded = email.keywords?.$forwarded;
const sender = email.from?.[0];
// Resolve color tag using keyword definitions from settings
@@ -175,6 +177,18 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
)}
+ {isAnswered && !isForwarded && (
+
+ )}
+ {isForwarded && !isAnswered && (
+
+ )}
+ {isAnswered && isForwarded && (
+ <>
+
+
+ >
+ )}
{email.hasAttachment && (
)}
diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx
index e9c9a782..4c927028 100644
--- a/components/email/thread-email-item.tsx
+++ b/components/email/thread-email-item.tsx
@@ -5,7 +5,7 @@ import { formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
-import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react";
+import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
import { useEmailStore } from "@/stores/email-store";
@@ -29,6 +29,8 @@ export function ThreadEmailItem({
}: ThreadEmailItemProps) {
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
+ const isAnswered = email.keywords?.$answered;
+ const isForwarded = email.keywords?.$forwarded;
const sender = email.from?.[0];
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const density = useSettingsStore((state) => state.density);
@@ -151,6 +153,18 @@ export function ThreadEmailItem({
{isStarred && (
)}
+ {isAnswered && !isForwarded && (
+
+ )}
+ {isForwarded && !isAnswered && (
+
+ )}
+ {isAnswered && isForwarded && (
+ <>
+
+
+ >
+ )}
{email.hasAttachment && (
)}
diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx
index 311c13cf..b2e5d13b 100644
--- a/components/email/thread-list-item.tsx
+++ b/components/email/thread-list-item.tsx
@@ -5,7 +5,7 @@ import { formatDate } from "@/lib/utils";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
-import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square } from "lucide-react";
+import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward } from "lucide-react";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store";
@@ -53,6 +53,8 @@ const SingleEmailItem = React.forwardRef(
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
+ const isAnswered = email.keywords?.$answered;
+ const isForwarded = email.keywords?.$forwarded;
const sender = email.from?.[0];
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
@@ -182,6 +184,18 @@ const SingleEmailItem = React.forwardRef(
{isStarred && (
)}
+ {isAnswered && !isForwarded && (
+
+ )}
+ {isForwarded && !isAnswered && (
+
+ )}
+ {isAnswered && isForwarded && (
+ <>
+
+
+ >
+ )}
{email.hasAttachment && (
)}
@@ -267,7 +281,7 @@ export const ThreadListItem = React.forwardRef state.showPreview);
const density = useSettingsStore((state) => state.density);
const isMobile = useUIStore((state) => state.isMobile);
- const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
+ const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
@@ -482,6 +496,18 @@ export const ThreadListItem = React.forwardRef
)}
+ {hasAnswered && !hasForwarded && (
+
+ )}
+ {hasForwarded && !hasAnswered && (
+
+ )}
+ {hasAnswered && hasForwarded && (
+ <>
+
+
+ >
+ )}
{hasAttachment && (
)}
diff --git a/components/providers/embedded-bridge-provider.tsx b/components/providers/embedded-bridge-provider.tsx
index 4f4cc0f1..3cb6edc4 100644
--- a/components/providers/embedded-bridge-provider.tsx
+++ b/components/providers/embedded-bridge-provider.tsx
@@ -2,6 +2,7 @@
import { useEffect } from "react";
import { isEmbedded, listenFromParent } from "@/lib/iframe-bridge";
+import { getPathPrefix, getLocaleFromPath } from "@/lib/browser-navigation";
import { useAuthStore } from "@/stores/auth-store";
import { useConfig } from "@/hooks/use-config";
@@ -16,9 +17,9 @@ export function EmbeddedBridgeProvider({ children }: { children: React.ReactNode
switch (msg.type) {
case "sso:trigger-login": {
// Navigate to login page to start SSO flow
- const segments = window.location.pathname.split("/").filter(Boolean);
- const locale = segments[0] || "en";
- window.location.href = `/${locale}/login`;
+ const prefix = getPathPrefix();
+ const locale = getLocaleFromPath();
+ window.location.href = `${prefix}/${locale}/login`;
break;
}
case "sso:trigger-logout":
diff --git a/components/settings/account-settings.tsx b/components/settings/account-settings.tsx
index 1083e0cb..3e70b9cd 100644
--- a/components/settings/account-settings.tsx
+++ b/components/settings/account-settings.tsx
@@ -3,29 +3,44 @@
import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
+import { useAccountStore } from '@/stores/account-store';
import { SettingsSection, SettingItem } from './settings-section';
import { formatFileSize } from '@/lib/utils';
export function AccountSettings() {
const t = useTranslations('settings.account');
- const { username, serverUrl, isDemoMode, primaryIdentity } = useAuthStore();
+ const { username, serverUrl, isDemoMode, primaryIdentity, authMode, activeAccountId } = useAuthStore();
const { quota } = useEmailStore();
+ const account = useAccountStore((s) => activeAccountId ? s.getAccountById(activeAccountId) : undefined);
const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0;
- const displayName = primaryIdentity?.name || (isDemoMode ? 'Demo User' : undefined);
+ const displayName = primaryIdentity?.name || account?.displayName || (isDemoMode ? 'Demo User' : undefined);
+ const email = primaryIdentity?.email || account?.email || username;
return (
- {/* Display Name (show in demo mode or when identity has a name) */}
- {displayName && (
-
- {displayName}
-
- )}
+ {/* Display Name */}
+
+ {displayName || t('../../common.unknown')}
+
{/* Email Address */}
- {username || t('../../common.unknown')}
+ {email || t('../../common.unknown')}
+
+
+ {/* Username / Login (show when it differs from email) */}
+ {username && username !== email && (
+
+ {username}
+
+ )}
+
+ {/* Authentication Method */}
+
+
+ {authMode === 'oauth' ? t('auth_method_oauth') : t('auth_method_basic')}
+
{/* Server */}
diff --git a/components/settings/calendar-settings.tsx b/components/settings/calendar-settings.tsx
index 96b6882b..9d714565 100644
--- a/components/settings/calendar-settings.tsx
+++ b/components/settings/calendar-settings.tsx
@@ -16,9 +16,6 @@ export function CalendarSettings() {
firstDayOfWeek,
showTimeInMonthView,
showWeekNumbers,
- calendarNotificationsEnabled,
- calendarNotificationSound,
- calendarInvitationParsingEnabled,
enableCalendarTasks,
showTasksOnCalendar,
updateSetting,
@@ -103,37 +100,6 @@ export function CalendarSettings() {
)}
-
- updateSetting('calendarNotificationsEnabled', checked)}
- />
-
-
-
- updateSetting('calendarNotificationSound', checked)}
- disabled={!calendarNotificationsEnabled}
- />
-
-
-
- updateSetting('calendarInvitationParsingEnabled', checked)}
- />
-
-
);
}
diff --git a/components/settings/email-settings.tsx b/components/settings/email-settings.tsx
index 9de84bfa..724b0d99 100644
--- a/components/settings/email-settings.tsx
+++ b/components/settings/email-settings.tsx
@@ -1,7 +1,8 @@
"use client";
-import { useState } from 'react';
+import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl';
+import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store';
import type { ArchiveMode, HoverAction } from '@/stores/settings-store';
import { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
@@ -10,13 +11,26 @@ import { useEmailStore } from '@/stores/email-store';
import { cn } from '@/lib/utils';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
-import { ChevronRight, AlertTriangle, FolderSync, Loader2 } from 'lucide-react';
+import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react';
export function EmailSettings() {
const t = useTranslations('settings.email_behavior');
+ const { appName } = useConfig();
const [showTrustedModal, setShowTrustedModal] = useState(false);
const [isReorganizing, setIsReorganizing] = useState(false);
const [reorganizeResult, setReorganizeResult] = useState(null);
+ const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle');
+
+ const handleSetDefaultMailProgram = useCallback(() => {
+ try {
+ if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) {
+ navigator.registerProtocolHandler('mailto', `${window.location.origin}/compose?mailto=%s`);
+ setDefaultMailStatus('success');
+ }
+ } catch {
+ setDefaultMailStatus('error');
+ }
+ }, []);
const {
markAsReadDelay,
@@ -280,6 +294,25 @@ export function EmailSettings() {
/>
+ {/* Default Mail Program */}
+
+
+
+ {defaultMailStatus === 'success' && (
+
{t('default_mail_program.success')}
+ )}
+ {defaultMailStatus === 'error' && (
+
{t('default_mail_program.error')}
+ )}
+
+
+
{/* Trusted Senders */}