Compare commits

...
8 Commits
Author SHA1 Message Date
Linus Rath 00b40fc48a chore: update version to 1.7.2 2026-05-28 20:31:03 +02:00
Linus Rath 856496715b i18n: add missing translation keys across 16 locales 2026-05-28 20:27:40 +02:00
Linus Rath eb7eeae1ac feat: per-domain branding editor in admin panel #332 2026-05-28 20:21:25 +02:00
Linus Rath 6b1a99a70b docs: document DOMAIN_BRANDING env var in examples and README #332 2026-05-28 20:13:11 +02:00
Linus Rath 1da04c254b feat: per-domain branding overrides on /api/config, manifest, pwa-icon #332 2026-05-28 20:06:47 +02:00
Linus Rath 8ae5ecba41 feat: policy-controlled push relay URL with optional user lock 2026-05-28 19:45:14 +02:00
Linus Rath 4ff05bd4ec fix: editable HTML signature in new mail; clean state on every compose entry #329 2026-05-28 19:21:44 +02:00
31e96d6a46 Feat: Scheduled send and send delay #322
* ADD DOC

* Scheduld Send

* add new shortcuts

* fix

* fix

* fix bugs

* rework

* fix draft duplicating

* fix err

* some fixes

* fixes from review

* fixes from review

* fixes from review

* disable password managers for recipients

* fix email store lazy load

* add translations

* fix styling

* fixes

---------

Co-authored-by: Linus Rath <139418639+rathlinus@users.noreply.github.com>
2026-05-28 18:46:49 +02:00
54 changed files with 4088 additions and 502 deletions
+5
View File
@@ -47,3 +47,8 @@ LOG_LEVEL=debug
# LOGIN_IMPRINT_URL=https://example.com/imprint
# LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
# LOGIN_WEBSITE_URL=https://example.com
# Per-domain branding overrides. Each entry must have "host" (exact or
# "*.subdomain" wildcard) plus any subset of branding fields to override.
# Unset fields fall through to the global values above.
# DOMAIN_BRANDING=[{"host":"localhost","loginCompanyName":"Local Dev"}]
+23
View File
@@ -225,6 +225,29 @@ LOGIN_COMPANY_NAME=Bulwark Webmail
# URL for the company website link on the login page.
LOGIN_WEBSITE_URL=https://bulwarkmail.org
# ---------------------------------------------------------------------------
# Per-domain branding overrides (optional)
# ---------------------------------------------------------------------------
#
# When you serve the webmail on multiple hostnames, each hostname can override
# a subset of branding fields. Unset fields fall back to the global values
# above. Match is on the request's Host (or X-Forwarded-Host) header.
#
# Use the leftmost label "*." to match any subdomain (e.g. "*.example.com"
# matches mail.example.com and any deeper subdomain, but NOT example.com).
# Exact matches always win over wildcards; the longest wildcard suffix wins
# among multiple wildcard matches.
#
# Overridable keys: appName, appShortName, appDescription, faviconUrl,
# pwaIconUrl, pwaThemeColor, pwaBackgroundColor, appLogoLightUrl,
# appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName,
# loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl.
#
# Prefer setting this from the admin dashboard (PATCH /api/admin/config).
# The env-var form is provided for stateless deployments.
#
# DOMAIN_BRANDING=[{"host":"maildomain1.com","loginCompanyName":"Company One","loginLogoLightUrl":"/branding/one-color.svg","loginLogoDarkUrl":"/branding/one-white.svg","loginWebsiteUrl":"https://one.example"},{"host":"maildomain2.com","loginCompanyName":"Company Two","faviconUrl":"/branding/two-favicon.svg"},{"host":"*.intranet.example.com","loginCompanyName":"Internal"}]
# =============================================================================
# Extension Directory / Marketplace
# =============================================================================
+46
View File
@@ -1,5 +1,51 @@
# Changelog
## 1.7.2 (2026-05-28)
### Features
- **Mail**: Scheduled send and send delay (#322)
- **Mail**: Drag emails out to the file explorer as `.eml`
- **Mail**: Import emails from `.zip` archives
- **Mail**: "Move to Trash and mark as read" delete action (#323)
- **Mail**: Include group inboxes in the unified mailbox view (#328)
- **Mail**: Locale-aware date format in the email list with a preset picker (#331)
- **Mail**: Allow drag-and-drop into shared mailboxes
- **Composer**: Ctrl/Cmd+Enter sends the open draft
- **Settings**: New Downloads tab with template editor for `.eml` and attachment filenames
- **Settings**: Filename transform settings and an ASCII-only "date (from-to) subject" template
- **Settings**: Post-export action (keep / archive / trash)
- **Settings**: Template for multi-email `.zip` filenames
- **Admin**: Per-domain branding editor with overrides on `/api/config`, manifest, and PWA icon (#332)
- **Admin**: Policy-controlled push relay URL with optional user lock
- **i18n**: `NEXT_PUBLIC_DEFAULT_LOCALE` for fallback UI locale (#243)
### Fixes
- **Mail**: Editable HTML signature in new mail; clean state on every compose entry (#329)
- **Mail**: Report real upload progress with XHR progress events (#333)
- **Mail**: Restore `blob:` in `object-src` and `frame-src` CSP for PDF/HTML previews
- **Mail**: Match user-avatar treatment on quick reply
- **Email viewer**: Stop shattering table cells with `word-break: break-word`
- **Composer**: Scope Ctrl/Cmd+Enter send to the focused composer
- **Composer**: Stop closing the form when editing any field
- **Pro**: Keep the empty viewer pane visible in the split layout
- **Pro**: Prevent an empty main pane when reordering tabs across panes
- **Mobile**: Collapse focus mail layout to multi-line
- **Mobile**: Keep a gutter on bare-HTML and plain-text emails
- **Calendar**: Align continued multi-week events with the week's left edge
- **Calendar**: Show the end date in the event popover for multi-day events (#318)
- **Calendar**: Convert `recurrenceRules` to singular in batch create
- **Calendar**: Handle malformed event dates (#316)
- **Files**: Stop URL-encoding drag-out filenames and preserve Unicode letters
- **Routing**: Prefix remaining `<img>`, favicon, and WebDAV URLs with `basePath` (#319)
- **Routing**: Prefix hand-written URLs with `basePath` for subpath deployments
- **Auth**: `OAUTH_ALLOW_PRIVATE_ENDPOINTS` for split-DNS setups
### i18n
- Add missing translation keys across 16 locales
## 1.7.1 (2026-05-22)
### Features
+7 -1
View File
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.7.1-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.7.2-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
[![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/)
@@ -211,6 +211,12 @@ LOGIN_COMPANY_NAME=My Company
LOGIN_WEBSITE_URL=https://example.com
LOGIN_IMPRINT_URL=https://example.com/imprint
LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy
# Per-domain overrides (optional). When the webmail is served on multiple
# hostnames, each host can override any subset of the branding fields above.
# Match is on the request Host (or X-Forwarded-Host). Use "*.example.com" to
# match any subdomain. Unset fields fall back to the global values.
DOMAIN_BRANDING=[{"host":"maildomain1.com","loginCompanyName":"Company One","loginLogoLightUrl":"/branding/one.svg"},{"host":"maildomain2.com","loginCompanyName":"Company Two"}]
```
</details>
+1 -1
View File
@@ -1 +1 @@
1.7.1
1.7.2
+374 -84
View File
@@ -75,6 +75,8 @@ import { buildQuoteHeader } from "@/lib/quote-header";
import { useLocaleStore } from "@/stores/locale-store";
import type { QuoteHeader } from "@/lib/plugin-types";
const SCHEDULED_MAILBOX_ID = '__scheduled__';
export default function Home() {
const t = useTranslations();
@@ -108,12 +110,39 @@ export default function Home() {
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastUndoToastSubmissionRef = useRef<string | null>(null);
const initialMailLoadClientRef = useRef<object | null>(null);
const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore();
const { identities } = useIdentityStore();
useIdentitySync();
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore();
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
const value = window.prompt(t('email_viewer.reschedule_prompt'));
if (!value) return null;
const time = new Date(value).getTime();
if (!Number.isFinite(time)) {
toast.error(t('email_composer.schedule_send_invalid'));
return null;
}
if (time <= Date.now()) {
toast.error(t('email_composer.schedule_send_future'));
return null;
}
if (!client?.hasDelayedSend()) {
toast.error(t('email_composer.schedule_send_unsupported'));
return null;
}
const maxDelayedSend = client.getMaxDelayedSend();
if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) {
toast.error(t('email_composer.schedule_send_too_late'));
return null;
}
return new Date(time).toISOString();
}, [client, t]);
// Load trusted senders address book when feature is enabled
useEffect(() => {
if (trustedSendersAddressBook && client && !trustedSendersLoaded) {
@@ -270,6 +299,21 @@ export default function Home() {
fetchTagCounts,
fetchEmailContent,
isUnifiedView,
scheduledEmails,
scheduledTotal,
scheduledHasMore,
isLoadingScheduled,
isScheduledView,
setScheduledView,
fetchScheduledEmails,
loadMoreScheduledEmails,
cancelScheduledEmail,
cancelScheduledEmailForEdit,
rescheduleScheduledEmail,
refreshScheduledMetadata,
cancelUndoSend,
clearPendingUndoSend,
pendingUndoSend,
fetchUnifiedEmails: fetchUnifiedEmailsAction,
refreshUnifiedCounts,
exitUnifiedView,
@@ -294,6 +338,10 @@ export default function Home() {
useProMultiAccountMailboxes();
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
const delayedSendSupported = client?.hasDelayedSend() ?? true;
const activeEmails = isScheduledView ? scheduledEmails : emails;
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
const accounts = useAccountStore((s) => s.accounts);
const connectedAccountsSignature = useMemo(
@@ -329,7 +377,7 @@ export default function Home() {
conversationThreadId: null as string | null,
});
navRestoreStateRef.current.client = client;
navRestoreStateRef.current.emails = emails;
navRestoreStateRef.current.emails = activeEmails;
navRestoreStateRef.current.mailboxes = mailboxes;
navRestoreStateRef.current.selectedMailbox = selectedMailbox;
navRestoreStateRef.current.selectedEmailId = selectedEmail?.id ?? null;
@@ -355,7 +403,24 @@ export default function Home() {
// Restore mailbox selection. selectMailbox clears the current email,
// which is fine because we re-apply the saved email below.
if (state.mailboxId && state.mailboxId !== ctx.selectedMailbox) {
if (state.mailboxId === SCHEDULED_MAILBOX_ID) {
if (!ctx.client?.hasDelayedSend()) {
setScheduledView(false);
selectEmail(null);
return;
}
setScheduledView(true);
selectMailbox(SCHEDULED_MAILBOX_ID);
selectEmail(null);
if (ctx.client) {
try {
await fetchScheduledEmails(ctx.client);
} catch (error) {
debug.error('Failed to fetch scheduled emails on history restore:', error);
}
}
} else if (state.mailboxId && state.mailboxId !== ctx.selectedMailbox) {
setScheduledView(false);
selectMailbox(state.mailboxId);
if (ctx.client) {
try {
@@ -416,19 +481,19 @@ export default function Home() {
// Keyboard shortcuts handlers
const keyboardHandlers = useMemo(() => ({
onNextEmail: () => {
if (emails.length === 0) return;
const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : -1;
const nextIndex = currentIndex < emails.length - 1 ? currentIndex + 1 : currentIndex;
if (nextIndex >= 0 && nextIndex < emails.length) {
handleEmailSelect(emails[nextIndex]);
if (activeEmails.length === 0) return;
const currentIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : -1;
const nextIndex = currentIndex < activeEmails.length - 1 ? currentIndex + 1 : currentIndex;
if (nextIndex >= 0 && nextIndex < activeEmails.length) {
handleEmailSelect(activeEmails[nextIndex]);
}
},
onPreviousEmail: () => {
if (emails.length === 0) return;
const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : emails.length;
if (activeEmails.length === 0) return;
const currentIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : activeEmails.length;
const prevIndex = currentIndex > 0 ? currentIndex - 1 : 0;
if (prevIndex >= 0 && prevIndex < emails.length) {
handleEmailSelect(emails[prevIndex]);
if (prevIndex >= 0 && prevIndex < activeEmails.length) {
handleEmailSelect(activeEmails[prevIndex]);
}
},
onOpenEmail: () => {
@@ -444,18 +509,23 @@ export default function Home() {
}
},
onReply: () => {
if (isScheduledView) return;
if (selectedEmail) handleReply();
},
onReplyAll: () => {
if (isScheduledView) return;
if (selectedEmail) handleReplyAll();
},
onForward: () => {
if (isScheduledView) return;
if (selectedEmail) handleForward();
},
onToggleStar: () => {
if (isScheduledView) return;
if (selectedEmail) handleToggleStar();
},
onArchive: async () => {
if (isScheduledView) return;
if (selectedEmailIds.size > 0 && client) {
try {
await batchArchive(client);
@@ -467,6 +537,7 @@ export default function Home() {
}
},
onDelete: async () => {
if (isScheduledView) return;
if (selectedEmailIds.size > 0 && client) {
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isInTrash = currentMailbox?.role === 'trash';
@@ -496,6 +567,7 @@ export default function Home() {
}
},
onMarkAsUnread: async () => {
if (isScheduledView) return;
if (!client) return;
if (selectedEmailIds.size > 0) {
await batchMarkAsRead(client, false);
@@ -504,6 +576,7 @@ export default function Home() {
}
},
onMarkAsRead: async () => {
if (isScheduledView) return;
if (!client) return;
if (selectedEmailIds.size > 0) {
await batchMarkAsRead(client, true);
@@ -512,6 +585,7 @@ export default function Home() {
}
},
onToggleSpam: async () => {
if (isScheduledView) return;
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isInJunk = currentMailbox?.role === 'junk';
if (selectedEmailIds.size > 0 && client) {
@@ -534,11 +608,13 @@ export default function Home() {
}
},
onCompose: () => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
},
onFocusSearch: () => {
if (isScheduledView) return;
const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement;
if (searchInput) {
searchInput.focus();
@@ -550,22 +626,27 @@ export default function Home() {
},
onRefresh: async () => {
if (client && selectedMailbox) {
await fetchEmails(client, selectedMailbox);
if (selectedMailbox === SCHEDULED_MAILBOX_ID) {
await fetchScheduledEmails(client);
} else {
await fetchEmails(client, selectedMailbox);
}
}
},
onSelectAll: () => {
if (isScheduledView) return;
selectAllEmails();
},
onDeselectAll: () => {
clearSelection();
},
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes]);
}), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes, isScheduledView]);
// Initialize keyboard shortcuts
useKeyboardShortcuts({
enabled: isAuthenticated && !showComposer,
emails,
emails: activeEmails,
selectedEmailId: selectedEmail?.id,
selectionCount: selectedEmailIds.size,
handlers: keyboardHandlers,
@@ -578,13 +659,36 @@ export default function Home() {
onRefresh: async () => {
if (!client) return;
const state = useEmailStore.getState();
await Promise.all([
state.fetchMailboxes(client),
state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client),
]);
if (state.isScheduledView || state.selectedMailbox === SCHEDULED_MAILBOX_ID) {
await Promise.all([
state.fetchMailboxes(client),
state.fetchScheduledEmails(client),
]);
} else {
await state.fetchMailboxes(client);
await state.refreshScheduledMetadata(client);
await (state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client));
}
},
});
useEffect(() => {
if (!delayedSendSupported && isScheduledView) {
setScheduledView(false);
}
}, [delayedSendSupported, isScheduledView, setScheduledView]);
useEffect(() => {
if (!pendingUndoSend) return;
const pendingSendTime = new Date(pendingUndoSend.sendAt).getTime();
if (!Number.isFinite(pendingSendTime) || pendingSendTime <= Date.now()) {
clearPendingUndoSend();
return;
}
const timer = setTimeout(clearPendingUndoSend, pendingSendTime - Date.now());
return () => clearTimeout(timer);
}, [clearPendingUndoSend, pendingUndoSend]);
// Update page title based on context
useEffect(() => {
let title = appName;
@@ -788,50 +892,61 @@ export default function Home() {
}, [isAuthenticated, client, handleMailtoProtocolRequest]);
// Fallback fetch for paths that didn't go through login()'s prefetch
// (notably checkAuth on page refresh). The prefetch in auth-store/login()
// populates mailboxes before this effect first runs, so on the post-login
// path this block is a no-op.
// (notably checkAuth on page refresh). Settings pages can also prefill
// mailboxes without emails, so bootstrap emails once per client when the
// mail route mounts even if mailbox data is already present.
useEffect(() => {
if (isAuthenticated && client && mailboxes.length === 0) {
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
const loadData = async (attempt = 1) => {
try {
await Promise.all([
fetchMailboxes(client),
fetchQuota(client)
]);
const state = useEmailStore.getState();
const selectedMailboxId = state.selectedMailbox;
if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) {
const delay = Math.min(1000 * attempt, 5000);
debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`);
retryTimer = setTimeout(() => loadData(attempt + 1), delay);
return;
}
if (selectedMailboxId) {
await fetchEmails(client, selectedMailboxId);
} else {
await fetchEmails(client);
}
fetchTagCounts(client);
} catch (error) {
console.error('Error loading email data:', error);
}
};
loadData();
return () => {
cancelled = true;
if (retryTimer) clearTimeout(retryTimer);
};
if (!isAuthenticated || !client) {
initialMailLoadClientRef.current = null;
return;
}
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts]);
if (initialMailLoadClientRef.current === client) return;
initialMailLoadClientRef.current = client;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
const loadData = async (attempt = 1) => {
try {
const needsMailboxes = useEmailStore.getState().mailboxes.length === 0;
await Promise.all([
needsMailboxes ? fetchMailboxes(client) : Promise.resolve(),
fetchQuota(client)
]);
const state = useEmailStore.getState();
const selectedMailboxId = state.selectedMailbox;
if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) {
const delay = Math.min(1000 * attempt, 5000);
debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`);
retryTimer = setTimeout(() => loadData(attempt + 1), delay);
return;
}
await refreshScheduledMetadata(client);
// Fetch emails for the selected mailbox after scheduled metadata is available.
if (selectedMailboxId) {
await fetchEmails(client, selectedMailboxId);
} else {
await fetchEmails(client);
}
fetchTagCounts(client);
} catch (error) {
console.error('Error loading email data:', error);
initialMailLoadClientRef.current = null;
}
};
loadData();
return () => {
cancelled = true;
if (retryTimer) clearTimeout(retryTimer);
};
}, [isAuthenticated, client, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, refreshScheduledMetadata]);
// Push notifications: set up once per client and tear down when the client
// goes away (logout or account switch). Kept separate from the fetch effect
@@ -938,7 +1053,7 @@ export default function Home() {
}
// Only set timeout if there's a selected email, it's unread, and we have a client
if (!selectedEmail || !client || selectedEmail.keywords?.$seen) {
if (!selectedEmail || !client || selectedEmail.keywords?.$seen || isScheduledView) {
return;
}
@@ -972,7 +1087,7 @@ export default function Home() {
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedEmail?.id]);
}, [selectedEmail?.id, isScheduledView]);
// Handle new email notifications - play sound
useEffect(() => {
@@ -1013,6 +1128,7 @@ export default function Home() {
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[];
references?: string[];
delayedUntil?: string;
}) => {
if (!client) return;
@@ -1020,8 +1136,13 @@ export default function Home() {
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, data.inReplyTo, data.references, data.envelopeMailFrom);
const result = 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, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom);
setShowComposer(false);
if (result.scheduled) {
await refreshScheduledMetadata(client);
if (isScheduledView) await fetchScheduledEmails(client);
return;
}
// Mark the original email with $answered or $forwarded keyword
if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) {
@@ -1039,7 +1160,7 @@ export default function Home() {
}
// Refresh the current mailbox to update the UI
await fetchEmails(client, selectedMailbox);
if (!isScheduledView) await fetchEmails(client, selectedMailbox);
} catch (error) {
console.error("Failed to send email:", error);
}
@@ -1099,6 +1220,15 @@ export default function Home() {
}
}, [tCommon]);
// Force a clean composer remount on every fresh entry point so prior
// compose state can't bleed into the new session (#329 C). The composer is
// keyed on composerSessionId, and pendingDraft would otherwise pin the
// composer to a stale draft from a discarded reply.
const startFreshComposerSession = useCallback(() => {
setComposerSessionId(id => id + 1);
setPendingDraft(null);
}, []);
const handleReply = async (draftText?: string) => {
if (selectedEmail) {
const ok = await emailHooks.onBeforeReply.intercept({
@@ -1111,6 +1241,7 @@ export default function Home() {
} else {
setComposerQuoteHeader(null);
}
startFreshComposerSession();
setComposerDraftText(draftText || "");
setComposerMode('reply');
setShowComposer(true);
@@ -1169,6 +1300,45 @@ export default function Home() {
if (isMobile) setActiveView('viewer');
};
useEffect(() => {
if (!pendingUndoSend || !client) return;
if (lastUndoToastSubmissionRef.current === pendingUndoSend.submissionId) return;
lastUndoToastSubmissionRef.current = pendingUndoSend.submissionId;
const pending = pendingUndoSend;
const undoDurationMs = Math.max(sendDelaySeconds, 8) * 1000;
toast.success(t('email_viewer.scheduled_send_created'), {
duration: undoDurationMs,
action: {
label: t('email_viewer.undo_send'),
onClick: () => {
void (async () => {
try {
const restored = await cancelUndoSend(client, pending);
if (restored && !pending.isSmime) {
await handleEditDraft(restored);
}
if (isScheduledView) await fetchScheduledEmails(client);
} catch (error) {
console.error('Failed to undo scheduled send:', error);
}
})();
},
},
});
const timer = setTimeout(() => {
const current = useEmailStore.getState().pendingUndoSend;
if (current?.submissionId === pending.submissionId) {
clearPendingUndoSend();
}
}, undoDurationMs);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cancelUndoSend, clearPendingUndoSend, client, fetchScheduledEmails, isScheduledView, pendingUndoSend?.submissionId, sendDelaySeconds, t]);
const handleReplyAll = async () => {
if (selectedEmail) {
const ok = await emailHooks.onBeforeReplyAll.intercept({
@@ -1181,6 +1351,7 @@ export default function Home() {
} else {
setComposerQuoteHeader(null);
}
startFreshComposerSession();
setComposerMode('replyAll');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -1198,6 +1369,7 @@ export default function Home() {
} else {
setComposerQuoteHeader(null);
}
startFreshComposerSession();
setComposerMode('forward');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -1435,7 +1607,29 @@ export default function Home() {
};
const handleMailboxSelect = async (mailboxId: string) => {
if (mailboxId === SCHEDULED_MAILBOX_ID) {
if (!delayedSendSupported) {
setScheduledView(false);
return;
}
if (isUnifiedView) exitUnifiedView();
setScheduledView(true);
selectMailbox(mailboxId);
selectEmail(null);
clearSelection();
if (isMobile) {
setSidebarOpen(false);
setActiveView("list");
}
if (isTablet) {
setTabletListVisible(true);
}
if (client) await fetchScheduledEmails(client);
return;
}
if (isUnifiedMailboxId(mailboxId)) {
setScheduledView(false);
const role = UNIFIED_ROLE_BY_ID[mailboxId];
if (!role) return;
@@ -1459,6 +1653,7 @@ export default function Home() {
if (isUnifiedView) {
exitUnifiedView();
}
setScheduledView(false);
selectMailbox(mailboxId);
selectEmail(null); // Clear selected email when switching mailboxes
@@ -1485,6 +1680,7 @@ export default function Home() {
};
const handleTagSelect = async (keywordId: string | null) => {
setScheduledView(false);
selectKeyword(keywordId);
// On mobile, close sidebar and go to list view
@@ -1899,6 +2095,16 @@ export default function Home() {
});
const originalEmailId = selectedEmail.id;
const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds;
let delayedUntil: string | undefined;
if (sendDelaySeconds > 0) {
if (!client.hasDelayedSend()) {
const confirmed = window.confirm(t('email_composer.send_delay_unsupported_confirm'));
if (!confirmed) return;
} else {
delayedUntil = new Date(Date.now() + sendDelaySeconds * 1000).toISOString();
}
}
// RFC 5322 §3.6.4 threading - keep the conversation stitched together (#234).
const threading = computeReplyThreadingHeaders({
@@ -1907,7 +2113,7 @@ export default function Home() {
});
// Send reply with just the body text
await sendEmail(
const result = await sendEmail(
client,
[sender.email],
`Re: ${selectedEmail.subject || "(no subject)"}`,
@@ -1922,9 +2128,15 @@ export default function Home() {
undefined,
threading?.inReplyTo,
threading?.references,
delayedUntil,
envelopeMailFrom,
);
if (result.scheduled) {
await refreshScheduledMetadata(client);
return;
}
// Mark the original email as answered
try {
await client.setKeyword(originalEmailId, '$answered');
@@ -1949,7 +2161,7 @@ export default function Home() {
}
// Get current mailbox name for mobile header
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
const currentMailboxName = isScheduledView ? t('sidebar.scheduled') : mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
const isFocusedMailLayout = mailLayout === 'focus';
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
@@ -1966,9 +2178,8 @@ export default function Home() {
setShowComposer(false);
}
// Show the list stub immediately so subject/sender render without
// waiting for the body fetch - avoids the loading flicker.
const listEmail = emails.find(e => e.id === email.id);
const listEmail = activeEmails.find(e => e.id === email.id);
if (listEmail) {
selectEmail(listEmail);
}
@@ -2005,6 +2216,14 @@ export default function Home() {
const fullEmail = await fetchClient.getEmail(email.id, accountId);
if (fullEmail) {
if (listEmail?.isScheduled) {
fullEmail.scheduledSendAt = listEmail.scheduledSendAt;
fullEmail.emailSubmissionId = listEmail.emailSubmissionId;
fullEmail.scheduledIdentityId = listEmail.scheduledIdentityId;
fullEmail.scheduledUndoStatus = listEmail.scheduledUndoStatus;
fullEmail.isScheduled = true;
fullEmail.isSmimeScheduled = listEmail.isSmimeScheduled;
}
if (emailAccountId) {
fullEmail.accountId = emailAccountId;
fullEmail.accountLabel = listEmail?.accountLabel;
@@ -2037,14 +2256,14 @@ export default function Home() {
};
// Navigate to next/previous email in the list
const selectedEmailIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : -1;
const selectedEmailIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : -1;
const handleNavigateNext = selectedEmailIndex >= 0 && selectedEmailIndex < emails.length - 1
? () => handleEmailSelect(emails[selectedEmailIndex + 1])
const handleNavigateNext = selectedEmailIndex >= 0 && selectedEmailIndex < activeEmails.length - 1
? () => handleEmailSelect(activeEmails[selectedEmailIndex + 1])
: undefined;
const handleNavigatePrev = selectedEmailIndex > 0
? () => handleEmailSelect(emails[selectedEmailIndex - 1])
? () => handleEmailSelect(activeEmails[selectedEmailIndex - 1])
: undefined;
// Handle opening conversation view on mobile
@@ -2072,6 +2291,7 @@ export default function Home() {
const handleConversationReply = async (email: Email) => {
selectEmail(email);
await prepareComposerQuoteHeader(email, 'reply');
startFreshComposerSession();
setComposerMode('reply');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -2080,6 +2300,7 @@ export default function Home() {
const handleConversationReplyAll = async (email: Email) => {
selectEmail(email);
await prepareComposerQuoteHeader(email, 'replyAll');
startFreshComposerSession();
setComposerMode('replyAll');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -2088,6 +2309,7 @@ export default function Home() {
const handleConversationForward = async (email: Email) => {
selectEmail(email);
await prepareComposerQuoteHeader(email, 'forward');
startFreshComposerSession();
setComposerMode('forward');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -2199,6 +2421,8 @@ export default function Home() {
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
selectedKeyword={selectedKeyword}
scheduledTotal={scheduledTotal}
showScheduledMailbox={delayedSendSupported}
onMailboxSelect={handleMailboxSelect}
onTagSelect={handleTagSelect}
onUnreadFilterClick={handleUnreadFilterClick}
@@ -2213,6 +2437,7 @@ export default function Home() {
onImportEmail={handleImportEmailFromContextMenu}
onRefreshMailboxes={handleRefreshMailboxes}
onCompose={() => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) {
@@ -2280,26 +2505,27 @@ export default function Home() {
type="button"
onClick={() => {
if (selectedEmailIds.size > 0) {
if (selectedEmailIds.size === emails.length) {
if (selectedEmailIds.size === activeEmails.length) {
clearSelection();
} else {
selectAllEmails();
}
} else if (emails.length > 0) {
} else if (activeEmails.length > 0) {
const currentId = selectedEmail?.id;
const target = currentId && emails.some((e) => e.id === currentId)
const target = currentId && activeEmails.some((e) => e.id === currentId)
? currentId
: emails[0].id;
: activeEmails[0].id;
toggleEmailSelection(target);
}
}}
disabled={isScheduledView}
className={cn(
"flex-shrink-0 p-2 rounded-md transition-colors",
selectedEmailIds.size > 0
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
title={selectedEmailIds.size > 0 ? (selectedEmailIds.size === emails.length ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')) : t('email_list.batch_actions.select')}
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : selectedEmailIds.size > 0 ? (selectedEmailIds.size === activeEmails.length ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')) : t('email_list.batch_actions.select')}
>
{selectedEmailIds.size > 0 ? (
<CheckSquare className="w-4 h-4" />
@@ -2317,6 +2543,8 @@ export default function Home() {
className={cn("pl-9 h-9", searchQuery && "pr-8")}
data-search-input
data-tour="search-input"
disabled={isUnifiedView || isScheduledView}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
/>
{searchQuery && (
<button
@@ -2332,13 +2560,15 @@ export default function Home() {
<button
type="button"
onClick={toggleAdvancedSearch}
disabled={isUnifiedView || isScheduledView}
className={cn(
"relative flex-shrink-0 p-2 rounded-md transition-colors",
(isUnifiedView || isScheduledView) && "opacity-50 cursor-not-allowed",
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
title={t("advanced_search.toggle_filters")}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : t("advanced_search.toggle_filters")}
>
<Filter className="w-4 h-4" />
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
@@ -2480,11 +2710,17 @@ export default function Home() {
)}
</div>
{(searchQuery || !isFilterEmpty(searchFilters)) && !isLoading && (
{(searchQuery || !isFilterEmpty(searchFilters)) && !activeIsLoading && !isScheduledView && (
<div className="px-4 py-1.5 text-xs text-muted-foreground border-b border-border bg-muted/20">
{hasMoreEmails
? t("advanced_search.results_found_more", { count: emails.length })
: t("advanced_search.results_found", { count: emails.length })}
{activeHasMore
? t("advanced_search.results_found_more", { count: activeEmails.length })
: t("advanced_search.results_found", { count: activeEmails.length })}
</div>
)}
{isScheduledView && !activeIsLoading && (
<div className="px-4 py-1.5 text-xs text-muted-foreground border-b border-border bg-muted/20">
{t('email_list.scheduled_count', { count: scheduledTotal })}
</div>
)}
@@ -2493,9 +2729,35 @@ export default function Home() {
<ErrorBoundary fallback={EmailListErrorFallback}>
<EmailList
emails={emails}
emails={activeEmails}
selectedEmailId={selectedEmail?.id}
isLoading={isLoading}
isLoading={activeIsLoading}
hasMore={activeHasMore}
isLoadingMoreItems={isScheduledView ? isLoadingScheduled && activeEmails.length > 0 : undefined}
isScheduledView={isScheduledView}
onLoadMoreScheduled={() => client && loadMoreScheduledEmails(client)}
onCancelScheduled={async (email) => {
if (client && email.emailSubmissionId) await cancelScheduledEmail(client, email.emailSubmissionId, email.id);
}}
onCancelScheduledForEdit={async (email) => {
if (!client) return;
const restored = await cancelScheduledEmailForEdit(client, email);
if (email.isSmimeScheduled) {
setComposerMode('compose');
setPendingDraft(null);
} else if (restored) {
await handleEditDraft(restored);
return;
}
setShowComposer(true);
if (isMobile) setActiveView('viewer');
}}
onRescheduleScheduled={async (email) => {
const delayedUntil = promptForRescheduleDelayedUntil();
if (delayedUntil && client && email.emailSubmissionId && email.scheduledIdentityId) {
await rescheduleScheduledEmail(client, email.emailSubmissionId, email.id, email.scheduledIdentityId, delayedUntil);
}
}}
onEmailSelect={handleEmailSelect}
onEmailDoubleClick={isEmbedded ? ((email) => {
useProTabStore.getState().openEmailTab({
@@ -2560,6 +2822,7 @@ export default function Home() {
{/* Floating Compose Button */}
<Button
onClick={() => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -2656,6 +2919,14 @@ export default function Home() {
await handleEmailSend(data);
setPendingDraft(null);
}}
onScheduledSendCreated={async () => {
if (client) {
await refreshScheduledMetadata(client);
if (isScheduledView) await fetchScheduledEmails(client);
}
setShowComposer(false);
setPendingDraft(null);
}}
onClose={() => {
setShowComposer(false);
setComposerMode('compose');
@@ -2751,7 +3022,26 @@ export default function Home() {
onNavigatePrev={handleNavigatePrev}
onShowShortcuts={() => setShowShortcutsModal(true)}
onEditDraft={handleEditDraft}
onCancelScheduled={async () => {
if (client && selectedEmail?.emailSubmissionId) await cancelScheduledEmail(client, selectedEmail.emailSubmissionId, selectedEmail.id);
}}
onCancelScheduledForEdit={async () => {
if (!client || !selectedEmail) return;
const restored = await cancelScheduledEmailForEdit(client, selectedEmail);
if (selectedEmail.isSmimeScheduled) {
setComposerMode('compose');
setShowComposer(true);
return;
}
if (restored) await handleEditDraft(restored);
}}
onRescheduleScheduled={async (delayedUntil) => {
if (client && selectedEmail?.emailSubmissionId && selectedEmail.scheduledIdentityId) {
await rescheduleScheduledEmail(client, selectedEmail.emailSubmissionId, selectedEmail.id, selectedEmail.scheduledIdentityId, delayedUntil);
}
}}
onCompose={() => {
startFreshComposerSession();
setComposerMode('compose');
setShowComposer(true);
}}
+312 -41
View File
@@ -1,8 +1,14 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
import {
BRANDING_OVERRIDE_KEYS,
parseDomainBranding,
type BrandingOverrideKey,
type DomainBrandingEntry,
} from '@/lib/admin/domain-branding';
interface ConfigEntry {
value?: unknown;
@@ -16,42 +22,65 @@ const IMAGE_FIELDS = [
{ key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
{ key: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
{ key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
];
] as const;
const TEXT_FIELDS = [
{ key: 'loginCompanyName', label: 'Company Name' },
{ key: 'loginImprintUrl', label: 'Imprint URL' },
{ key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' },
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
];
] as const;
const PWA_IMAGE_FIELDS = [
{ key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' },
];
] as const;
const PWA_TEXT_FIELDS = [
{ key: 'appShortName', label: 'Short Name', placeholder: 'Shown on home screen (max ~12 chars)' },
{ key: 'appDescription', label: 'Description', placeholder: 'App description for install prompts' },
];
] as const;
const PWA_COLOR_FIELDS = [
{ key: 'pwaThemeColor', label: 'Theme Color', defaultValue: '#ffffff' },
{ key: 'pwaBackgroundColor', label: 'Background Color', defaultValue: '#ffffff' },
];
] as const;
// Accepts exact hosts and one-level wildcards (e.g. *.example.com).
const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
// Tighter rule for uploads: wildcards can only point to externally-hosted
// URLs, since we'd have no concrete subdomain to serve a file from.
const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
export function BrandingTab() {
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
const [edits, setEdits] = useState<Record<string, unknown>>({});
const [edits, setEdits] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [selectedHost, setSelectedHost] = useState<string | null>(null);
const [addingHost, setAddingHost] = useState(false);
const [newHostInput, setNewHostInput] = useState('');
const [newHostError, setNewHostError] = useState<string | null>(null);
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
useEffect(() => {
fetchConfig();
}, []);
const domainEntries = useMemo<DomainBrandingEntry[]>(
() => parseDomainBranding(config['domainBranding']?.value),
[config],
);
// Drop selection if the host disappeared from the config (e.g. concurrent edit).
useEffect(() => {
if (selectedHost && !domainEntries.some(e => e.host === selectedHost)) {
setSelectedHost(null);
setEdits({});
}
}, [domainEntries, selectedHost]);
async function fetchConfig() {
setLoading(true);
const res = await apiFetch('/api/admin/config');
@@ -59,29 +88,81 @@ export function BrandingTab() {
setLoading(false);
}
function selectedEntry(): DomainBrandingEntry | null {
if (!selectedHost) return null;
return domainEntries.find(e => e.host === selectedHost) ?? null;
}
function handleChange(key: string, value: string) {
setEdits(prev => ({ ...prev, [key]: value }));
setMessage(null);
}
function currentValue(key: string): string {
if (key in edits) return edits[key] as string;
if (key in edits) return edits[key];
if (selectedHost) {
const entry = selectedEntry();
return (entry?.[key as BrandingOverrideKey] as string | undefined) ?? '';
}
return (config[key]?.value as string) ?? '';
}
function isOverriddenInScope(key: string): boolean {
if (selectedHost) {
const entry = selectedEntry();
const v = entry?.[key as BrandingOverrideKey];
return typeof v === 'string' && v.length > 0;
}
return config[key]?.source === 'admin';
}
const isUploadedFile = (key: string): boolean => {
const val = currentValue(key);
return val.startsWith('/api/admin/branding/');
};
function buildUpdatedDomainBranding(merge: Record<string, string>): DomainBrandingEntry[] {
if (!selectedHost) return domainEntries;
const next = domainEntries.slice();
const idx = next.findIndex(e => e.host === selectedHost);
const base: DomainBrandingEntry =
idx === -1 ? { host: selectedHost } : { ...next[idx] };
const writable = base as unknown as Record<string, string | undefined>;
for (const [key, value] of Object.entries(merge)) {
if (!(BRANDING_OVERRIDE_KEYS as readonly string[]).includes(key)) continue;
if (typeof value === 'string' && value.length > 0) {
writable[key] = value;
} else {
delete writable[key];
}
}
if (idx === -1) next.push(base);
else next[idx] = base;
return next;
}
async function handleSave() {
if (Object.keys(edits).length === 0) return;
setSaving(true);
setMessage(null);
const payload = selectedHost
? { domainBranding: buildUpdatedDomainBranding(edits) }
: edits;
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edits),
body: JSON.stringify(payload),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Branding updated. Changes visible on next page load.' });
setMessage({
type: 'success',
text: selectedHost
? `Branding for ${selectedHost} updated. Changes visible on next page load.`
: 'Branding updated. Changes visible on next page load.',
});
setEdits({});
await fetchConfig();
} else {
@@ -92,12 +173,20 @@ export function BrandingTab() {
}
async function handleUpload(slot: string, file: File) {
if (selectedHost && !EXACT_HOST_RE.test(selectedHost)) {
setMessage({
type: 'error',
text: 'Wildcard hosts cannot upload files. Enter a URL instead.',
});
return;
}
setUploading(slot);
setMessage(null);
const formData = new FormData();
formData.append('file', file);
formData.append('slot', slot);
if (selectedHost) formData.append('host', selectedHost);
const res = await apiFetch('/api/admin/branding', {
method: 'POST',
@@ -112,10 +201,9 @@ export function BrandingTab() {
delete next[slot];
return next;
});
setConfig(prev => ({
...prev,
[slot]: { value: data.url, source: 'admin' },
}));
// Refresh from server so domainBranding entries reflect the upload.
await fetchConfig();
void data;
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Upload failed' });
@@ -126,10 +214,13 @@ export function BrandingTab() {
async function handleDeleteUpload(slot: string) {
setMessage(null);
const body: { slot: string; host?: string } = { slot };
if (selectedHost) body.host = selectedHost;
const res = await apiFetch('/api/admin/branding', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slot }),
body: JSON.stringify(body),
});
if (res.ok) {
@@ -147,6 +238,25 @@ export function BrandingTab() {
}
async function handleRevert(key: string) {
if (selectedHost) {
// Domain scope: drop the field from the entry and PATCH the array.
const updated = buildUpdatedDomainBranding({ [key]: '' });
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domainBranding: updated }),
});
if (res.ok) {
setEdits(prev => {
const next = { ...prev };
delete next[key];
return next;
});
await fetchConfig();
}
return;
}
// Default scope: revert via DELETE /api/admin/config
const res = await apiFetch('/api/admin/config', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
@@ -162,12 +272,71 @@ export function BrandingTab() {
}
}
const isUploadedFile = (key: string): boolean => {
const val = currentValue(key);
return val.startsWith('/api/admin/branding/');
};
async function handleAddDomain() {
const host = newHostInput.trim().toLowerCase().replace(/\.+$/, '');
if (!host) {
setNewHostError('Enter a hostname');
return;
}
if (!HOST_RE.test(host)) {
setNewHostError('Invalid hostname. Use foo.example.com or *.example.com');
return;
}
if (domainEntries.some(e => e.host === host)) {
setNewHostError('A branding entry for this host already exists');
return;
}
setNewHostError(null);
const next: DomainBrandingEntry[] = [...domainEntries, { host }];
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domainBranding: next }),
});
if (res.ok) {
setNewHostInput('');
setAddingHost(false);
setSelectedHost(host);
setEdits({});
await fetchConfig();
} else {
const data = await res.json();
setNewHostError(data.error || 'Failed to add domain');
}
}
async function handleDeleteDomain() {
if (!selectedHost) return;
if (!confirm(`Remove branding entry for ${selectedHost}? Uploaded files for this domain will be left behind on disk.`)) {
return;
}
const next = domainEntries.filter(e => e.host !== selectedHost);
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domainBranding: next }),
});
if (res.ok) {
setSelectedHost(null);
setEdits({});
await fetchConfig();
setMessage({ type: 'success', text: `Removed branding entry for ${selectedHost}.` });
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to remove domain' });
}
}
function handleScopeChange(host: string | null) {
if (Object.keys(edits).length > 0 && !confirm('Discard unsaved changes?')) return;
setSelectedHost(host);
setEdits({});
setMessage(null);
}
const hasEdits = Object.keys(edits).length > 0;
const wildcardScope = !!selectedHost && !EXACT_HOST_RE.test(selectedHost);
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
@@ -192,6 +361,102 @@ export function BrandingTab() {
)}
</div>
{/* Scope picker */}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30 flex items-center gap-2">
<Globe className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Scope</h2>
</div>
<div className="px-4 py-3 space-y-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => handleScopeChange(null)}
className={`h-8 px-3 rounded-md text-sm font-medium transition-colors ${
selectedHost === null
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground hover:bg-muted/70'
}`}
>
Default
</button>
{domainEntries.map(entry => (
<button
key={entry.host}
type="button"
onClick={() => handleScopeChange(entry.host)}
className={`h-8 px-3 rounded-md text-sm font-medium transition-colors ${
selectedHost === entry.host
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground hover:bg-muted/70'
}`}
>
{entry.host}
</button>
))}
{!addingHost && (
<button
type="button"
onClick={() => { setAddingHost(true); setNewHostError(null); }}
className="inline-flex items-center gap-1 h-8 px-3 rounded-md border border-dashed border-input text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Add domain
</button>
)}
</div>
{addingHost && (
<div className="flex flex-wrap items-center gap-2">
<input
type="text"
autoFocus
value={newHostInput}
onChange={(e) => { setNewHostInput(e.target.value); setNewHostError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter') void handleAddDomain(); }}
placeholder="mail.example.com or *.example.com"
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<button
type="button"
onClick={handleAddDomain}
className="h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
Add
</button>
<button
type="button"
onClick={() => { setAddingHost(false); setNewHostInput(''); setNewHostError(null); }}
className="h-8 px-2.5 rounded-md text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
{newHostError && <span className="text-xs text-destructive">{newHostError}</span>}
</div>
)}
{selectedHost ? (
<div className="flex items-center justify-between gap-3 text-xs">
<p className="text-muted-foreground">
Editing overrides for <span className="font-mono text-foreground">{selectedHost}</span>.
Unset fields fall back to the Default values.
{wildcardScope && ' Uploads are disabled for wildcard hosts; enter a URL instead.'}
</p>
<button
type="button"
onClick={handleDeleteDomain}
className="inline-flex items-center gap-1 text-destructive hover:underline whitespace-nowrap"
>
<X className="w-3.5 h-3.5" />
Remove domain
</button>
</div>
) : (
<p className="text-xs text-muted-foreground">
Editing the Default branding. Add a domain to override branding when the webmail is served on a specific hostname.
</p>
)}
</div>
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
@@ -209,9 +474,9 @@ export function BrandingTab() {
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && (
{isOverriddenInScope(field.key) && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
{isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'}
</span>
)}
</div>
@@ -220,7 +485,7 @@ export function BrandingTab() {
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder="Enter URL or upload a file"
placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'}
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<input
@@ -236,9 +501,9 @@ export function BrandingTab() {
/>
<button
onClick={() => fileInputRefs.current[field.key]?.click()}
disabled={uploading === field.key}
disabled={uploading === field.key || wildcardScope}
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
title="Upload file"
title={wildcardScope ? 'Uploads disabled for wildcard hosts' : 'Upload file'}
>
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
</button>
@@ -251,7 +516,7 @@ export function BrandingTab() {
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && (
{isOverriddenInScope(field.key) && !isUploadedFile(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
@@ -287,9 +552,9 @@ export function BrandingTab() {
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && (
{isOverriddenInScope(field.key) && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{isUploadedFile(field.key) ? 'uploaded' : 'admin'}
{isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'}
</span>
)}
</div>
@@ -298,7 +563,7 @@ export function BrandingTab() {
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder="Enter URL or upload a file"
placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'}
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<input
@@ -314,9 +579,9 @@ export function BrandingTab() {
/>
<button
onClick={() => fileInputRefs.current[field.key]?.click()}
disabled={uploading === field.key}
disabled={uploading === field.key || wildcardScope}
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
title="Upload file"
title={wildcardScope ? 'Uploads disabled for wildcard hosts' : 'Upload file'}
>
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
</button>
@@ -329,7 +594,7 @@ export function BrandingTab() {
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && (
{isOverriddenInScope(field.key) && !isUploadedFile(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
@@ -355,8 +620,10 @@ export function BrandingTab() {
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
{isOverriddenInScope(field.key) && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{selectedHost ? 'domain' : 'admin'}
</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
@@ -367,7 +634,7 @@ export function BrandingTab() {
placeholder={field.placeholder}
className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{config[field.key]?.source === 'admin' && (
{isOverriddenInScope(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
@@ -381,8 +648,10 @@ export function BrandingTab() {
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
{isOverriddenInScope(field.key) && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{selectedHost ? 'domain' : 'admin'}
</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
@@ -400,7 +669,7 @@ export function BrandingTab() {
placeholder={field.defaultValue}
className="h-8 w-full sm:w-32 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{config[field.key]?.source === 'admin' && (
{isOverriddenInScope(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
@@ -421,8 +690,10 @@ export function BrandingTab() {
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{config[field.key]?.source === 'admin' && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
{isOverriddenInScope(field.key) && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{selectedHost ? 'domain' : 'admin'}
</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
@@ -433,7 +704,7 @@ export function BrandingTab() {
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{config[field.key]?.source === 'admin' && (
{isOverriddenInScope(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
+40
View File
@@ -74,6 +74,18 @@ export function PolicyTab() {
setMessage(null);
}
function setPushRelayUrl(value: string) {
setPolicy(prev => ({ ...prev, pushRelayUrl: value }));
setDirty(true);
setMessage(null);
}
function togglePushRelayLocked() {
setPolicy(prev => ({ ...prev, pushRelayUrlLocked: !prev.pushRelayUrlLocked }));
setDirty(true);
setMessage(null);
}
function toggleLocked(settingKey: string) {
setPolicy(prev => {
const existing = prev.restrictions[settingKey] || {};
@@ -183,6 +195,34 @@ export function PolicyTab() {
</div>
</div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Push Relay</h2>
<p className="text-xs text-muted-foreground mt-0.5">Override the Web Push relay URL shown in user notification settings. Leave empty to use the built-in default.</p>
</div>
<div className="px-4 py-3 space-y-3">
<input
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
value={policy.pushRelayUrl ?? ''}
onChange={(e) => setPushRelayUrl(e.target.value)}
placeholder="https://notifications.relay.example.com"
className="w-full rounded border border-input bg-background px-3 py-2 text-sm"
/>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={!!policy.pushRelayUrlLocked}
onChange={togglePushRelayLocked}
className="rounded border-input"
/>
<Lock className="w-3 h-3" /> Lock - users cannot change this URL
</label>
</div>
</div>
{categories.map(category => (
<div key={category} className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
+155 -32
View File
@@ -3,8 +3,13 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { configManager } from '@/lib/admin/config-manager';
import { getConfigDir } from '@/lib/admin/paths';
import {
parseDomainBranding,
type DomainBrandingEntry,
type BrandingOverrideKey,
} from '@/lib/admin/domain-branding';
import { logger } from '@/lib/logger';
import { writeFile, unlink, mkdir } from 'node:fs/promises';
import { writeFile, unlink, mkdir, readdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
@@ -22,7 +27,7 @@ const ALLOWED_MIME_TYPES = new Set([
]);
/** Slots that correspond to branding config keys */
const VALID_SLOTS = new Set([
const VALID_SLOTS = new Set<BrandingOverrideKey>([
'faviconUrl',
'pwaIconUrl',
'appLogoLightUrl',
@@ -31,17 +36,85 @@ const VALID_SLOTS = new Set([
'loginLogoDarkUrl',
]);
const EXT_BY_MIME: Record<string, string> = {
'image/svg+xml': '.svg',
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp',
'image/x-icon': '.ico',
'image/vnd.microsoft.icon': '.ico',
};
const POSSIBLE_EXTS = ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.ico'];
// Exact hostnames only (no wildcards): wildcards can't be uploaded against
// because we'd need a real subdomain to serve the file from.
const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
function sanitizeFilename(name: string): string {
// Strip directory traversal, keep only safe chars
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
}
function normalizeHost(raw: string): string {
return raw.trim().toLowerCase().replace(/\.+$/, '');
}
/** Filename used to store a per-host uploaded asset. */
function domainAssetName(host: string, slot: BrandingOverrideKey, ext: string): string {
return sanitizeFilename(`domain__${host}__${slot}${ext}`);
}
/** True if the file belongs to the given host+slot (any extension). */
function isDomainAssetFor(filename: string, host: string, slot: BrandingOverrideKey): boolean {
const prefix = sanitizeFilename(`domain__${host}__${slot}.`);
return filename.startsWith(prefix);
}
/** Merge a per-host update into the existing domainBranding array. */
function mergeDomainEntry(
current: DomainBrandingEntry[],
host: string,
patch: Partial<DomainBrandingEntry>,
): DomainBrandingEntry[] {
const next = current.slice();
const idx = next.findIndex(e => e.host === host);
if (idx === -1) {
next.push({ host, ...patch });
} else {
next[idx] = { ...next[idx], ...patch };
}
return next;
}
/** Remove keys from a host's entry. If the entry has nothing left besides
* `host`, drop it entirely. */
function clearDomainKeys(
current: DomainBrandingEntry[],
host: string,
keys: BrandingOverrideKey[],
): DomainBrandingEntry[] {
const idx = current.findIndex(e => e.host === host);
if (idx === -1) return current;
const entry = { ...current[idx] };
for (const key of keys) delete (entry as Record<string, unknown>)[key];
const next = current.slice();
if (Object.keys(entry).filter(k => k !== 'host').length === 0) {
next.splice(idx, 1);
} else {
next[idx] = entry;
}
return next;
}
/**
* POST /api/admin/branding - Upload a branding image file
*
* Expects multipart/form-data with:
* - file: the image file
* - slot: which branding field this is for (e.g. "faviconUrl")
* - host (optional): when set, the upload is stored against the
* per-domain entry for that hostname instead of the global default.
*/
export async function POST(request: NextRequest) {
try {
@@ -52,15 +125,24 @@ export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get('file') as File | null;
const slot = formData.get('slot') as string | null;
const rawHost = (formData.get('host') as string | null) ?? '';
if (!file || !slot) {
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
}
if (!VALID_SLOTS.has(slot)) {
if (!VALID_SLOTS.has(slot as BrandingOverrideKey)) {
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
}
const host = rawHost ? normalizeHost(rawHost) : '';
if (host && !EXACT_HOST_RE.test(host)) {
return NextResponse.json(
{ error: `Invalid host: ${rawHost} (wildcards must be configured by URL, not upload)` },
{ status: 400 },
);
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
}
@@ -72,34 +154,51 @@ export async function POST(request: NextRequest) {
);
}
// Determine extension from mime type
const extMap: Record<string, string> = {
'image/svg+xml': '.svg',
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp',
'image/x-icon': '.ico',
'image/vnd.microsoft.icon': '.ico',
};
const ext = extMap[file.type] || '.png';
const safeName = sanitizeFilename(`${slot}${ext}`);
const ext = EXT_BY_MIME[file.type] ?? '.png';
const safeName = host
? domainAssetName(host, slot as BrandingOverrideKey, ext)
: sanitizeFilename(`${slot}${ext}`);
const filePath = path.join(getBrandingDir(), safeName);
// Ensure branding directory exists
if (!existsSync(getBrandingDir())) {
await mkdir(getBrandingDir(), { recursive: true });
}
// Write file to disk
// Strip any prior asset for the same slot but a different extension so
// the directory doesn't accumulate orphan files on re-upload.
const dir = getBrandingDir();
const allFiles = await readdir(dir).catch(() => [] as string[]);
for (const f of allFiles) {
if (f === safeName) continue;
const isSame = host
? isDomainAssetFor(f, host, slot as BrandingOverrideKey)
: POSSIBLE_EXTS.some(e => f === `${slot}${e}`);
if (isSame) {
try { await unlink(path.join(dir, f)); } catch { /* ignore */ }
}
}
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(filePath, buffer);
// Update config to point to the served URL
const servedUrl = `/api/admin/branding/${safeName}`;
await configManager.ensureLoaded();
await configManager.setAdminConfig({ [slot]: servedUrl });
await auditLog('branding_upload', { slot, filename: safeName, size: file.size, mimeType: file.type }, ip);
if (host) {
const current = parseDomainBranding(configManager.get<unknown>('domainBranding', []));
const next = mergeDomainEntry(current, host, { [slot]: servedUrl });
await configManager.setAdminConfig({ domainBranding: next });
} else {
await configManager.setAdminConfig({ [slot]: servedUrl });
}
await auditLog('branding_upload', {
slot,
host: host || undefined,
filename: safeName,
size: file.size,
mimeType: file.type,
}, ip);
return NextResponse.json({ url: servedUrl, filename: safeName });
} catch (error) {
@@ -111,7 +210,11 @@ export async function POST(request: NextRequest) {
/**
* DELETE /api/admin/branding - Remove an uploaded branding file
*
* Expects JSON body: { slot: string }
* Expects JSON body: { slot: string, host?: string }
*
* When `host` is provided, only the per-domain asset for that host+slot is
* removed (and the override in `domainBranding[host][slot]` is cleared).
* Otherwise the global asset and config override are removed.
*/
export async function DELETE(request: NextRequest) {
try {
@@ -119,28 +222,48 @@ export async function DELETE(request: NextRequest) {
if ('error' in result) return result.error;
const ip = getClientIP(request);
const { slot } = await request.json();
const body = await request.json().catch(() => ({})) as { slot?: string; host?: string };
const slot = body.slot;
const rawHost = body.host ?? '';
if (!slot || !VALID_SLOTS.has(slot)) {
if (!slot || !VALID_SLOTS.has(slot as BrandingOverrideKey)) {
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
}
// Find and remove matching files for this slot
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
const host = rawHost ? normalizeHost(rawHost) : '';
if (host && !EXACT_HOST_RE.test(host)) {
return NextResponse.json({ error: `Invalid host: ${rawHost}` }, { status: 400 });
}
const dir = getBrandingDir();
let removed = false;
for (const ext of possibleExts) {
const filePath = path.join(getBrandingDir(), `${slot}${ext}`);
if (existsSync(filePath)) {
await unlink(filePath);
removed = true;
if (host) {
const allFiles = await readdir(dir).catch(() => [] as string[]);
for (const f of allFiles) {
if (isDomainAssetFor(f, host, slot as BrandingOverrideKey)) {
try { await unlink(path.join(dir, f)); removed = true; } catch { /* ignore */ }
}
}
} else {
for (const ext of POSSIBLE_EXTS) {
const filePath = path.join(dir, `${slot}${ext}`);
if (existsSync(filePath)) {
await unlink(filePath);
removed = true;
}
}
}
// Clear the config override so it falls back to default/env
await configManager.ensureLoaded();
await configManager.removeAdminOverride(slot);
if (host) {
const current = parseDomainBranding(configManager.get<unknown>('domainBranding', []));
const next = clearDomainKeys(current, host, [slot as BrandingOverrideKey]);
await configManager.setAdminConfig({ domainBranding: next });
} else {
await configManager.removeAdminOverride(slot);
}
await auditLog('branding_delete', { slot, fileRemoved: removed }, ip);
await auditLog('branding_delete', { slot, host: host || undefined, fileRemoved: removed }, ip);
return NextResponse.json({ success: true });
} catch (error) {
+20
View File
@@ -4,6 +4,7 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
import { parseJmapServers } from '@/lib/admin/jmap-servers';
import { parseDomainBranding } from '@/lib/admin/domain-branding';
import { logger } from '@/lib/logger';
// Strings that count as "no real secret configured" - used so the dashboard
@@ -88,6 +89,25 @@ export async function PATCH(request: NextRequest) {
updates.jmapServers = sanitized;
}
// Normalize domainBranding: drop entries with an invalid/missing host or
// duplicate hosts before persisting. Each entry's branding field strings
// are passed through unchanged (URL/string content is the operator's
// responsibility, same as the flat branding fields).
if ('domainBranding' in updates) {
const incoming = updates.domainBranding;
if (incoming != null && !Array.isArray(incoming)) {
return NextResponse.json({ error: 'domainBranding must be an array' }, { status: 400 });
}
const sanitized = parseDomainBranding(incoming);
const incomingCount = Array.isArray(incoming) ? incoming.length : 0;
if (sanitized.length !== incomingCount) {
return NextResponse.json({
error: 'One or more domainBranding entries are invalid (each needs a unique, valid host).',
}, { status: 400 });
}
updates.domainBranding = sanitized;
}
// Get old values for audit
const oldValues: Record<string, unknown> = {};
for (const key of Object.keys(updates)) {
+66 -36
View File
@@ -1,9 +1,15 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { getOauthScopes } from '@/lib/oauth/tokens';
import {
matchDomainBranding,
parseDomainBranding,
pickRequestHost,
type BrandingOverrideKey,
} from '@/lib/admin/domain-branding';
/**
* Runtime configuration endpoint
@@ -13,49 +19,73 @@ import { getOauthScopes } from '@/lib/oauth/tokens';
* post-build configuration for Docker deployments.
*
* Priority order:
* 1. Admin dashboard overrides (data/admin/config.json)
* 2. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
* 3. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
* 4. Default values
* 1. Per-domain branding override (admin-configured, matched on request host)
* 2. Admin dashboard overrides (data/admin/config.json)
* 3. Runtime env vars (APP_NAME, JMAP_SERVER_URL)
* 4. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL)
* 5. Default values
*/
export async function GET() {
export async function GET(request: NextRequest) {
logger.debug('Config requested');
await configManager.ensureLoaded();
const appName = configManager.get<string>('appName') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
const host = pickRequestHost(request);
const domainOverrides = matchDomainBranding(
host,
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
);
// Per-domain override wins over the global value, but only when the
// entry explicitly sets that key. Otherwise we fall through to the
// global admin/env/default chain.
const branded = <T,>(key: BrandingOverrideKey, fallback: T): T => {
const override = domainOverrides[key];
if (typeof override === 'string' && override.length > 0) return override as T;
return configManager.get<T>(key, fallback);
};
const appName =
branded<string>('appName', '') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
const jmapServerUrl = configManager.get<string>('jmapServerUrl') || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '';
const oauthEnabled = configManager.get<boolean>('oauthEnabled', false);
const oauthOnly = oauthEnabled && configManager.get<boolean>('oauthOnly', false);
const stalwartFeaturesEnabled = configManager.get<boolean>('stalwartFeaturesEnabled', true);
const allowedFrameAncestors = configManager.get<string>('allowedFrameAncestors', '');
return NextResponse.json({
appName,
jmapServerUrl,
oauthEnabled,
oauthOnly,
oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
oauthScopes: getOauthScopes(),
rememberMeEnabled: hasSessionSecret(),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
stalwartFeaturesEnabled,
devMode: configManager.get<boolean>('devMode', false),
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
appLogoLightUrl: configManager.get<string>('appLogoLightUrl', ''),
appLogoDarkUrl: configManager.get<string>('appLogoDarkUrl', ''),
loginLogoLightUrl: configManager.get<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
loginLogoDarkUrl: configManager.get<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
loginCompanyName: configManager.get<string>('loginCompanyName', ''),
loginImprintUrl: configManager.get<string>('loginImprintUrl', ''),
loginPrivacyPolicyUrl: configManager.get<string>('loginPrivacyPolicyUrl', ''),
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
demoMode: configManager.get<boolean>('demoMode', false),
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
jmapServerAutoPickByDomain: configManager.get<boolean>('jmapServerAutoPickByDomain', false),
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get<string>('parentOrigin', ''),
});
return NextResponse.json(
{
appName,
jmapServerUrl,
oauthEnabled,
oauthOnly,
oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
oauthScopes: getOauthScopes(),
rememberMeEnabled: hasSessionSecret(),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
stalwartFeaturesEnabled,
devMode: configManager.get<boolean>('devMode', false),
faviconUrl: branded<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
appLogoLightUrl: branded<string>('appLogoLightUrl', ''),
appLogoDarkUrl: branded<string>('appLogoDarkUrl', ''),
loginLogoLightUrl: branded<string>('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'),
loginLogoDarkUrl: branded<string>('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'),
loginCompanyName: branded<string>('loginCompanyName', ''),
loginImprintUrl: branded<string>('loginImprintUrl', ''),
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''),
demoMode: configManager.get<boolean>('demoMode', false),
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
jmapServerAutoPickByDomain: configManager.get<boolean>('jmapServerAutoPickByDomain', false),
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get<string>('parentOrigin', ''),
},
{
// Branding varies by host, so any cache between us and the browser
// must key its entry by the host headers we consulted.
headers: { Vary: 'Host, X-Forwarded-Host' },
},
);
}
+51 -3
View File
@@ -10,6 +10,8 @@ import { NextRequest, NextResponse } from 'next/server';
*/
const ACCOUNT_ID = 'dev-account-001';
const scheduledSubmissions: Array<{ id: string; emailId: string; identityId: string; sendAt: string; undoStatus: 'pending' | 'final' | 'canceled' }> = [];
const emailCreationIds = new Map<string, string>();
// ---------------------------------------------------------------------------
// Mailboxes
@@ -1533,6 +1535,7 @@ function handleEmailSet(args: MethodArgs, callId: string): MethodResult {
bodyValues: {},
};
emails.unshift(newEmail);
emailCreationIds.set(key, newId);
created[key] = { id: newId };
}
}
@@ -1575,8 +1578,51 @@ function handleThreadGet(args: MethodArgs, callId: string): MethodResult {
return ['Thread/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
}
function handleEmailSubmissionSet(_args: MethodArgs, callId: string): MethodResult {
return ['EmailSubmission/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: { 'sub-1': { id: 'sub-mock-1' } }, notCreated: null }, callId];
function handleEmailSubmissionSet(args: MethodArgs, callId: string): MethodResult {
const created: Record<string, { id: string; sendAt?: string }> = {};
const updated: Record<string, null> = {};
const create = args.create as Record<string, { emailId?: string; identityId?: string; envelope?: { mailFrom?: { parameters?: { HOLDFOR?: string; HOLDUNTIL?: string } } } }> | undefined;
if (create) {
for (const [key, value] of Object.entries(create)) {
const id = `submission-${Date.now()}-${key}`;
const holdFor = value.envelope?.mailFrom?.parameters?.HOLDFOR;
const holdUntil = value.envelope?.mailFrom?.parameters?.HOLDUNTIL;
const holdForSeconds = holdFor ? Number(holdFor) : Number.NaN;
const holdUntilTime = Number.isFinite(holdForSeconds) && holdForSeconds > 0
? Date.now() + holdForSeconds * 1000
: holdUntil ? new Date(holdUntil).getTime() : Number.NaN;
const delayedUntil = Number.isFinite(holdUntilTime) ? new Date(holdUntilTime).toISOString() : undefined;
created[key] = { id, ...(delayedUntil ? { sendAt: delayedUntil } : {}) };
if (delayedUntil && value.emailId && value.identityId) {
const emailId = value.emailId.startsWith('#') ? emailCreationIds.get(value.emailId.slice(1)) || value.emailId : value.emailId;
scheduledSubmissions.push({ id, emailId, identityId: value.identityId, sendAt: delayedUntil, undoStatus: 'pending' });
}
}
}
const update = args.update as Record<string, { undoStatus?: 'pending' | 'final' | 'canceled' }> | undefined;
if (update) {
for (const [id, patch] of Object.entries(update)) {
const submission = scheduledSubmissions.find(s => s.id === id);
if (submission && patch.undoStatus) {
submission.undoStatus = patch.undoStatus;
updated[id] = null;
}
}
}
return ['EmailSubmission/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, notCreated: null, notUpdated: null }, callId];
}
function handleEmailSubmissionQuery(args: MethodArgs, callId: string): MethodResult {
const position = Number(args.position || 0);
const limit = Number(args.limit || 50);
const submissions = [...scheduledSubmissions].sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
return ['EmailSubmission/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids: submissions.slice(position, position + limit).map(s => s.id), total: submissions.length, position, canCalculateChanges: false }, callId];
}
function handleEmailSubmissionGet(args: MethodArgs, callId: string): MethodResult {
const ids = args.ids as string[] | undefined;
const list = ids ? scheduledSubmissions.filter(s => ids.includes(s.id)) : scheduledSubmissions;
return ['EmailSubmission/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
}
function handleQuotaGet(_args: MethodArgs, callId: string): MethodResult {
@@ -1639,6 +1685,8 @@ const METHOD_HANDLERS: Record<string, (args: MethodArgs, callId: string) => Meth
'Identity/get': handleIdentityGet,
'Identity/set': handleIdentitySet,
'EmailSubmission/set': handleEmailSubmissionSet,
'EmailSubmission/query': handleEmailSubmissionQuery,
'EmailSubmission/get': handleEmailSubmissionGet,
'Quota/get': handleQuotaGet,
'VacationResponse/get': handleVacationResponseGet,
'VacationResponse/set': (_args, callId) => ['VacationResponse/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), updated: { 'vacation-1': null } }, callId],
@@ -1776,7 +1824,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
isReadOnly: false,
accountCapabilities: {
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:submission': {},
'urn:ietf:params:jmap:submission': { maxDelayedSend: 2592000, submissionExtensions: { FUTURERELEASE: true } },
'urn:ietf:params:jmap:quota': {},
'urn:ietf:params:jmap:vacationresponse': {},
'urn:ietf:params:jmap:contacts': {},
+14 -1
View File
@@ -4,6 +4,11 @@ import path from 'node:path';
import { readFile } from 'node:fs/promises';
import { configManager } from '@/lib/admin/config-manager';
import { getConfigDir } from '@/lib/admin/paths';
import {
matchDomainBranding,
parseDomainBranding,
pickRequestHost,
} from '@/lib/admin/domain-branding';
const VALID_SIZES = new Set([192, 512]);
@@ -33,7 +38,7 @@ async function fetchSourceImage(iconUrl: string): Promise<Buffer> {
}
export async function GET(
_req: NextRequest,
req: NextRequest,
{ params }: { params: Promise<{ size: string }> }
) {
const { size: sizeParam } = await params;
@@ -44,8 +49,15 @@ export async function GET(
}
await configManager.ensureLoaded();
const host = pickRequestHost(req);
const domainOverrides = matchDomainBranding(
host,
parseDomainBranding(configManager.get<unknown>('domainBranding', [])),
);
const sources = configManager.getAllWithSources();
const iconUrl =
domainOverrides.pwaIconUrl ||
domainOverrides.faviconUrl ||
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '');
if (!iconUrl) {
@@ -55,6 +67,7 @@ export async function GET(
const pngHeaders = {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400',
Vary: 'Host, X-Forwarded-Host',
};
const cacheKey = `${size}|${iconUrl}`;
+32 -10
View File
@@ -1,5 +1,12 @@
import type { MetadataRoute } from "next";
import { headers } from "next/headers";
import { configManager } from "@/lib/admin/config-manager";
import {
matchDomainBranding,
parseDomainBranding,
pickRequestHost,
type BrandingOverrideKey,
} from "@/lib/admin/domain-branding";
export const dynamic = "force-dynamic";
@@ -25,25 +32,40 @@ const withBase = (p: string) => `${BASE_PATH}${p}`;
export default async function manifest(): Promise<ExtendedManifest> {
await configManager.ensureLoaded();
const host = pickRequestHost(await headers());
const domainOverrides = matchDomainBranding(
host,
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
);
const branded = <T,>(key: BrandingOverrideKey, fallback: T): T => {
const override = domainOverrides[key];
if (typeof override === "string" && override.length > 0) return override as T;
return configManager.get<T>(key, fallback);
};
const appName =
configManager.get<string>("appName") ||
branded<string>("appName", "") ||
process.env.NEXT_PUBLIC_APP_NAME ||
"Bulwark Webmail";
const shortName = configManager.get<string>("appShortName") || appName;
const shortName = branded<string>("appShortName", "") || appName;
const description =
configManager.get<string>("appDescription") ||
branded<string>("appDescription", "") ||
"A modern webmail client built for Stalwart Mail Server";
const themeColor = configManager.get<string>("pwaThemeColor") || "#ffffff";
const backgroundColor = configManager.get<string>("pwaBackgroundColor") || "#ffffff";
const themeColor = branded<string>("pwaThemeColor", "") || "#ffffff";
const backgroundColor = branded<string>("pwaBackgroundColor", "") || "#ffffff";
// If pwaIconUrl or faviconUrl was explicitly configured (admin override or
// env var), serve dynamically resized PNGs via /api/pwa-icon/[size].
// Otherwise fall back to the static Bulwark PNGs - sources marked "default"
// are the built-in placeholder paths and not real custom icons.
// If pwaIconUrl or faviconUrl was explicitly configured (admin override,
// env var, or per-domain override), serve dynamically resized PNGs via
// /api/pwa-icon/[size]. Otherwise fall back to the static Bulwark PNGs -
// sources marked "default" are the built-in placeholder paths and not
// real custom icons.
const sources = configManager.getAllWithSources();
const hasCustomIcon =
sources.pwaIconUrl?.source !== "default" || sources.faviconUrl?.source !== "default";
!!domainOverrides.pwaIconUrl ||
!!domainOverrides.faviconUrl ||
sources.pwaIconUrl?.source !== "default" ||
sources.faviconUrl?.source !== "default";
const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon
? [
+273 -26
View File
@@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock } from "lucide-react";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown } from "lucide-react";
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
@@ -88,7 +88,9 @@ interface EmailComposerProps {
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[];
references?: string[];
delayedUntil?: string;
}) => void | Promise<void>;
onScheduledSendCreated?: () => void | Promise<void>;
onClose?: () => void;
onDiscardDraft?: (draftId: string) => void;
onSaveState?: (data: ComposerDraftData) => void;
@@ -168,8 +170,21 @@ function buildEmbeddedSignatureHtml(
return '';
}
function formatLocalDateTimeInput(date: Date): string {
const pad = (value: number) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function getDefaultScheduleValue(): string {
const tomorrowAtEight = new Date();
tomorrowAtEight.setDate(tomorrowAtEight.getDate() + 1);
tomorrowAtEight.setHours(8, 0, 0, 0);
return formatLocalDateTimeInput(tomorrowAtEight);
}
export function EmailComposer({
onSend,
onScheduledSendCreated,
onClose,
onDiscardDraft,
onSaveState,
@@ -187,6 +202,7 @@ export function EmailComposer({
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
const activeIdentities = useIdentityStore((s) => s.identities);
@@ -212,10 +228,16 @@ export function EmailComposer({
const initialSignatureIdentity = (initialCurrentIdentityForSig?.htmlSignature || initialCurrentIdentityForSig?.textSignature)
? initialCurrentIdentityForSig
: primaryIdentity;
const hasInitialSignature = !!(initialSignatureIdentity?.htmlSignature || initialSignatureIdentity?.textSignature);
const shouldEmbedSignatureAboveQuote =
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
signaturePosition === 'above_quote' &&
!!(initialSignatureIdentity?.htmlSignature || initialSignatureIdentity?.textSignature);
hasInitialSignature;
// New-mail composes always embed the signature into the editor body so it's
// editable/removable (the previous read-only preview below the editor was
// never spec-correct - see #329). Compose mode also ignores any leftover
// `replyTo` from a still-selected email; getInitialBody short-circuits below.
const shouldEmbedSignatureInNewMail = mode === 'compose' && hasInitialSignature;
// Initialize with reply/forward data if provided
const getInitialTo = () => {
@@ -256,6 +278,14 @@ export function EmailComposer({
if (plainTextMode) {
// Plain text mode: produce plain text body with no HTML
const prefix = initialDraftText || "";
// Compose mode: ignore any leftover replyTo (e.g. a selected mail in the
// viewer) and embed the signature directly into the body so it's
// editable. Fixes #329 (A,B).
if (mode === 'compose') {
if (!shouldEmbedSignatureInNewMail) return prefix;
const sep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n';
return `${prefix}${sep}${getPlainTextSignature(initialSignatureIdentity)}`;
}
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
@@ -289,6 +319,18 @@ export function EmailComposer({
}
const prefix = initialDraftText ? `<p>${initialDraftText.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</p>` : "";
// Compose mode: ignore any leftover replyTo and embed the signature
// directly into the body so the user can edit/delete it. The leading
// empty paragraph gives the cursor a place to land above the signature.
if (mode === 'compose') {
if (!shouldEmbedSignatureInNewMail) return prefix;
const composePrefix = prefix || '<p></p>';
const embedded = buildEmbeddedSignatureHtml(initialSignatureIdentity, {
embed: true,
separator: signatureSeparatorEnabled,
});
return `${composePrefix}${embedded}`;
}
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
@@ -390,6 +432,12 @@ export function EmailComposer({
const [smimePassphraseError, setSmimePassphraseError] = useState('');
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
const [attachmentWarningDelayedUntil, setAttachmentWarningDelayedUntil] = useState<string | undefined>();
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
const [scheduleValue, setScheduleValue] = useState('');
const [scheduleError, setScheduleError] = useState('');
const [showSendMenu, setShowSendMenu] = useState(false);
const sendMenuRef = useRef<HTMLDivElement>(null);
const saveTemplateModalRef = useFocusTrap({
isActive: showSaveAsTemplate,
@@ -449,8 +497,11 @@ export function EmailComposer({
if (!editor) return;
if (!identityChanged && !separatorChanged) return;
if (plainTextMode) return;
if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
if (signaturePosition !== 'above_quote') return;
// Replies/forwards only embed when configured for "above quote". Compose
// always embeds (see getInitialBody), so swap on identity change there too.
const isReplyLike = mode === 'reply' || mode === 'replyAll' || mode === 'forward';
if (!isReplyLike && mode !== 'compose') return;
if (isReplyLike && signaturePosition !== 'above_quote') return;
const currentHtml = editor.getHTML();
const doc = new DOMParser().parseFromString(currentHtml, 'text/html');
@@ -495,6 +546,23 @@ export function EmailComposer({
}
}, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
useEffect(() => {
const handleClickOutsideSendMenu = (event: MouseEvent) => {
if (!sendMenuRef.current?.contains(event.target as Node)) {
setShowSendMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutsideSendMenu);
return () => document.removeEventListener('mousedown', handleClickOutsideSendMenu);
}, []);
const openScheduleDialog = useCallback(() => {
setScheduleError('');
setScheduleValue(getDefaultScheduleValue());
setShowScheduleDialog(true);
setShowSendMenu(false);
}, []);
useEffect(() => {
if (!autoSelectReplyIdentity) return;
if (selectedIdentityId || initialData?.selectedIdentityId) return;
@@ -664,9 +732,16 @@ export function EmailComposer({
// Ref to latest saveDraft for use in event handlers with stale closures
const saveDraftRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
// Set by the explicit close paths (clean close, save-and-close, discard) so
// the unmount auto-save below doesn't fire and stash a stale pendingDraft
// on the parent (#329 D). Without this, "Reply → Discard → New mail" would
// open the next composer with the discarded reply's mode/replyTo.
const explicitCloseRef = useRef(false);
// Auto-save state on unmount (when user navigates away without explicitly closing)
useEffect(() => {
return () => {
if (explicitCloseRef.current) return;
if (onSaveState && isDirtyRef.current) {
const s = stateRef.current;
onSaveState({
@@ -1184,6 +1259,33 @@ export function EmailComposer({
return undefined;
};
const validateScheduleValue = (value: string): string | null => {
if (!value) return t('schedule_send_required');
const time = new Date(value).getTime();
if (!Number.isFinite(time)) return t('schedule_send_invalid');
if (time <= Date.now()) return t('schedule_send_future');
if (composerClient) {
const maxDelayedSend = composerClient.getMaxDelayedSend();
if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) {
return t('schedule_send_too_late');
}
}
return null;
};
const resolveDelayedUntil = async (requestedDelayedUntil?: string): Promise<string | undefined> => {
if (requestedDelayedUntil) return requestedDelayedUntil;
if (sendDelaySeconds === 0) return undefined;
if (composerClient?.hasDelayedSend()) {
return new Date(Date.now() + sendDelaySeconds * 1000).toISOString();
}
const confirmed = window.confirm(t('send_delay_unsupported_confirm'));
if (!confirmed) {
throw new Error(t('send_delay_unsupported'));
}
return undefined;
};
// Rewrite data: URLs of dropped images (tagged with data-cid) into cid:
// references so recipient clients that strip data URIs can still render them.
const rewriteInlineImages = (html: string): {
@@ -1227,7 +1329,7 @@ export function EmailComposer({
};
};
const handleSend = async (skipAttachmentCheck = false) => {
const handleSend = async (skipAttachmentCheck = false, delayedUntil?: string) => {
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
@@ -1255,6 +1357,7 @@ export function EmailComposer({
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
if (matched) {
setAttachmentWarningKeyword(matched);
setAttachmentWarningDelayedUntil(delayedUntil);
setShowAttachmentWarning(true);
return;
}
@@ -1305,12 +1408,14 @@ export function EmailComposer({
const envelopeMailFrom = overrideActive ? identityFromEmail : undefined;
// Body is already HTML from the rich text editor (or plain text in plain text mode).
// When "above quote" mode is configured for replies/forwards, the signature
// was embedded into the body during init (see getInitialBody) so the
// trailing append must be skipped to avoid duplicating it.
// The signature is embedded into the body during init for compose mode
// (when the initial identity had a signature) and for above-quote
// replies/forwards - skip the trailing append in those cases so we don't
// duplicate it.
const signatureAlreadyInBody =
(mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
signaturePosition === 'above_quote';
shouldEmbedSignatureInNewMail ||
((mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
signaturePosition === 'above_quote');
// Build HTML signature block (used only in rich text mode)
const buildSignatureHtml = (): string => {
@@ -1343,6 +1448,7 @@ export function EmailComposer({
const inlineAttachments = rewritten?.attachments ?? [];
try {
const effectiveDelayedUntil = await resolveDelayedUntil(delayedUntil);
// Let plugins veto the send (external-mail warning, mistyped-domain
// guards, etc.). Returning false from any handler aborts before either
// the S/MIME or standard JMAP path runs.
@@ -1492,7 +1598,16 @@ export function EmailComposer({
}
// 7. Send via raw email path
await sendRawEmail(client, payload, currentIdentity.id);
const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveDelayedUntil, [...toAddresses, ...ccAddresses, ...bccAddresses]);
if (effectiveDelayedUntil && finalDraftId) {
client.deleteEmail(finalDraftId).catch(err => {
debug.warn('email', 'Scheduled S/MIME send created, but plaintext draft cleanup failed:', err);
toast.warning(t('schedule_send_cleanup_warning'));
});
}
if (result.scheduled) {
await onScheduledSendCreated?.();
}
} else {
// Standard JMAP send path
// Collect uploaded attachment blobIds for the send request
@@ -1542,6 +1657,7 @@ export function EmailComposer({
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
inReplyTo: threadingHeaders?.inReplyTo,
references: threadingHeaders?.references,
delayedUntil: effectiveDelayedUntil,
});
if (mode === 'reply' || mode === 'replyAll') {
@@ -1566,14 +1682,30 @@ export function EmailComposer({
setDraftId(null);
setSubAddressTag("");
setValidationErrors({});
setShowScheduleDialog(false);
setScheduleValue('');
setScheduleError('');
// Clear ref so unmount effect doesn't re-save
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' };
} catch (err) {
debug.error('Failed to send email:', err);
toast.error(t('send_failed'));
toast.error(err instanceof Error ? err.message : t('send_failed'));
}
};
const handleScheduleSend = () => {
if (!composerClient?.hasDelayedSend()) {
setScheduleError(t('schedule_send_unsupported'));
return;
}
const error = validateScheduleValue(scheduleValue);
if (error) {
setScheduleError(error);
return;
}
handleSend(false, new Date(scheduleValue).toISOString());
};
// Ctrl+Enter (Win/Linux) / Cmd+Enter (macOS) sends the open compose
// draft. Scoped to events whose target lives inside this composer's
// DOM tree — in Pro mode multiple composer tabs can be mounted at
@@ -1599,6 +1731,7 @@ export function EmailComposer({
}, []);
const cleanClose = () => {
explicitCloseRef.current = true;
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
@@ -1607,6 +1740,7 @@ export function EmailComposer({
};
const handleSaveDraftAndClose = async () => {
explicitCloseRef.current = true;
setShowCloseDialog(false);
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
@@ -1617,6 +1751,7 @@ export function EmailComposer({
};
const handleDiscardAndClose = () => {
explicitCloseRef.current = true;
setShowCloseDialog(false);
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
@@ -1636,6 +1771,48 @@ export function EmailComposer({
}
};
const handleComposerKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.defaultPrevented) return;
const isPlainEscape = e.key === 'Escape' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey;
const hasPrimaryModifier = e.ctrlKey || e.metaKey;
const isSendShortcut = e.key === 'Enter' && hasPrimaryModifier && !e.altKey && !e.shiftKey;
const isScheduleShortcut = e.key === 'Enter' && hasPrimaryModifier && !e.altKey && e.shiftKey;
if (!isPlainEscape && !isSendShortcut && !isScheduleShortcut) return;
if (
showTemplatePicker ||
showSaveAsTemplate ||
showScheduleDialog ||
smimePassphrasePrompt ||
showAttachmentWarning ||
showCloseDialog
) return;
if (isPlainEscape) {
if (activeAutoField) return;
e.preventDefault();
handleClose();
return;
}
if (isSendShortcut) {
if (e.repeat) {
e.preventDefault();
return;
}
e.preventDefault();
handleSend();
return;
}
if (isScheduleShortcut) {
e.preventDefault();
if (!e.repeat && composerClient?.hasDelayedSend()) openScheduleDialog();
}
};
return (
<div ref={composerRootRef} className={cn("flex h-full bg-background", className)}>
<PluginSlot
@@ -1650,6 +1827,7 @@ export function EmailComposer({
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onKeyDown={handleComposerKeyDown}
>
{/* Drag overlay */}
{isDraggingOver && (
@@ -1970,8 +2148,9 @@ export function EmailComposer({
)}
{/* Hide the visual signature preview when the signature has already been
embedded into the body above the quote (otherwise it would appear twice). */}
{((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote') ? null
embedded into the body (compose, or above-quote replies). */}
{(shouldEmbedSignatureInNewMail
|| ((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote')) ? null
: plainTextMode ? (
getPlainTextSignature(signatureIdentity) ? (
<div className="px-4 pb-3 text-sm leading-6 text-muted-foreground break-words whitespace-pre-wrap font-mono">
@@ -2077,7 +2256,6 @@ export function EmailComposer({
>
<BookmarkPlus className="w-4 h-4" />
</Button>
{/* S/MIME toggles */}
{canSmimeSign && (
<>
@@ -2115,15 +2293,56 @@ export function EmailComposer({
>
{t('discard')}
</button>
<Button
onClick={() => handleSend()}
disabled={!canSend}
title={getSendTooltip()}
className="hidden md:inline-flex"
>
<Send className="w-4 h-4 mr-2" />
{t('send')}
</Button>
{composerClient?.hasDelayedSend() ? (
<div ref={sendMenuRef} className="relative hidden md:inline-flex">
<Button
onClick={() => handleSend()}
disabled={!canSend}
title={getSendTooltip()}
className="rounded-r-none border-r border-primary-foreground/20"
>
<Send className="w-4 h-4 mr-2" />
{t('send')}
</Button>
<Button
type="button"
onClick={() => setShowSendMenu((open) => !open)}
disabled={!canSend}
title={t('schedule_send')}
className="rounded-l-none px-2"
aria-haspopup="menu"
aria-expanded={showSendMenu}
>
<ChevronDown className="w-4 h-4" />
</Button>
{showSendMenu && (
<div
role="menu"
className="absolute right-0 bottom-full z-50 mb-2 min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg"
>
<button
type="button"
role="menuitem"
onClick={openScheduleDialog}
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground"
>
<CalendarClock className="w-4 h-4" />
{t('schedule_send')}
</button>
</div>
)}
</div>
) : (
<Button
onClick={() => handleSend()}
disabled={!canSend}
title={getSendTooltip()}
className="hidden md:inline-flex"
>
<Send className="w-4 h-4 mr-2" />
{t('send')}
</Button>
)}
</div>
</div>
@@ -2162,6 +2381,29 @@ export function EmailComposer({
</div>
)}
{showScheduleDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
<div className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md p-6 animate-in zoom-in-95 duration-200">
<h3 className="text-lg font-semibold text-foreground mb-2">{t('schedule_send')}</h3>
<p className="text-sm text-muted-foreground mb-4">{t('schedule_send_description')}</p>
<Input
type="datetime-local"
value={scheduleValue}
onChange={(e) => {
setScheduleValue(e.target.value);
setScheduleError('');
}}
className={cn(scheduleError && "border-destructive focus-visible:ring-destructive")}
/>
{scheduleError && <p className="mt-2 text-sm text-destructive">{scheduleError}</p>}
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setShowScheduleDialog(false)}>{tCommon('cancel')}</Button>
<Button onClick={handleScheduleSend} disabled={!canSend}>{t('schedule_send')}</Button>
</div>
</div>
</div>
)}
{/* S/MIME passphrase prompt */}
{smimePassphrasePrompt && (
<div
@@ -2238,7 +2480,7 @@ export function EmailComposer({
<Button variant="outline" onClick={() => setShowAttachmentWarning(false)}>
{t('forgot_attachment.back')}
</Button>
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true); }}>
<Button onClick={() => { setShowAttachmentWarning(false); handleSend(true, attachmentWarningDelayedUntil); setAttachmentWarningDelayedUntil(undefined); }}>
{t('forgot_attachment.send_anyway')}
</Button>
</div>
@@ -2485,6 +2727,11 @@ function RecipientChipInput({
aria-controls={activeAutoField === field ? `autocomplete-${field}` : undefined}
aria-activedescendant={activeAutoField === field && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined}
aria-invalid={validationError || undefined}
data-bwignore="true"
data-1p-ignore
data-op-ignore
data-lpignore="true"
data-form-type="other"
/>
</div>
{validationError && validationMessage && (
@@ -2501,4 +2748,4 @@ function RecipientChipInput({
)}
</div>
);
}
}
+42 -2
View File
@@ -30,6 +30,8 @@ import {
ShieldAlert,
ShieldCheck,
EditIcon,
CalendarClock,
XCircle,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
@@ -63,6 +65,9 @@ interface EmailContextMenuProps {
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
onEditDraft?: () => void;
onCancelScheduled?: () => void;
onCancelScheduledForEdit?: () => void;
onRescheduleScheduled?: () => void;
// Batch actions
onBatchMarkAsRead?: (read: boolean) => void;
onBatchDelete?: () => void;
@@ -133,6 +138,9 @@ export function EmailContextMenu({
onBatchMarkAsSpam,
onBatchUndoSpam,
onEditDraft,
onCancelScheduled,
onCancelScheduledForEdit,
onRescheduleScheduled,
}: EmailContextMenuProps) {
const t = useTranslations("context_menu");
const _tColor = useTranslations("email_viewer.color_tag");
@@ -143,6 +151,8 @@ export function EmailContextMenu({
const currentColors = getCurrentColors(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk';
const isScheduled = email.isScheduled === true;
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
// Build color options from keyword definitions in settings
const colorOptions = emailKeywords.map((kw) => ({
@@ -196,8 +206,36 @@ export function EmailContextMenu({
</ContextMenuHeader>
)}
{isScheduled && !showBatchActions && canCancelScheduled && (
<>
<ContextMenuItem
icon={CalendarClock}
label={t("reschedule_send")}
onClick={() => handleAction(onRescheduleScheduled!)}
disabled={!onRescheduleScheduled}
/>
<ContextMenuItem
icon={XCircle}
label={t("cancel_scheduled_send")}
onClick={() => handleAction(onCancelScheduled!)}
disabled={!onCancelScheduled}
/>
<ContextMenuItem
icon={EditIcon}
label={email.isSmimeScheduled ? t("cancel_and_compose_again") : t("cancel_and_edit")}
onClick={() => handleAction(onCancelScheduledForEdit!)}
disabled={!onCancelScheduledForEdit}
/>
</>
)}
{canCancelScheduled && <ContextMenuSeparator />}
{!isScheduled && (
<>
{/* Edit Draft - only for single draft emails */}
{!showBatchActions && isDraft && onEditDraft && (
{!isScheduled && !showBatchActions && isDraft && onEditDraft && (
<>
<ContextMenuItem
icon={EditIcon}
@@ -209,7 +247,7 @@ export function EmailContextMenu({
)}
{/* Single email actions - Reply, Reply All, Forward */}
{!showBatchActions && (
{!isScheduled && !showBatchActions && (
<>
<ContextMenuItem
icon={Reply}
@@ -375,6 +413,8 @@ export function EmailContextMenu({
)
}
/>
</>
)}
<PluginSlot name="context-menu-email" />
</ContextMenu>
+35 -10
View File
@@ -4,7 +4,7 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
import { ThreadListItem } from "./thread-list-item";
import { EmailContextMenu } from "./email-context-menu";
import { cn } from "@/lib/utils";
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle } from "lucide-react";
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock } from "lucide-react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
@@ -27,6 +27,8 @@ interface EmailListProps {
onEmailDoubleClick?: (email: Email) => void;
className?: string;
isLoading?: boolean;
hasMore?: boolean;
isLoadingMoreItems?: boolean;
onOpenConversation?: (thread: ThreadGroup) => void;
onReply?: (email: Email) => void;
onReplyAll?: (email: Email) => void;
@@ -40,6 +42,11 @@ interface EmailListProps {
onMarkAsSpam?: (email: Email) => void;
onUndoSpam?: (email: Email) => void;
onEditDraft?: (email: Email) => void;
isScheduledView?: boolean;
onLoadMoreScheduled?: () => void;
onCancelScheduled?: (email: Email) => void | Promise<void>;
onCancelScheduledForEdit?: (email: Email) => void | Promise<void>;
onRescheduleScheduled?: (email: Email) => void | Promise<void>;
}
export function EmailList({
@@ -49,6 +56,8 @@ export function EmailList({
onEmailDoubleClick,
className,
isLoading = false,
hasMore,
isLoadingMoreItems,
onOpenConversation,
onReply,
onReplyAll,
@@ -62,6 +71,11 @@ export function EmailList({
onUndoSpam,
onMoveToMailbox,
onEditDraft,
isScheduledView = false,
onLoadMoreScheduled,
onCancelScheduled,
onCancelScheduledForEdit,
onRescheduleScheduled,
}: EmailListProps) {
const t = useTranslations('email_list');
const { client } = useAuthStore();
@@ -96,9 +110,9 @@ export function EmailList({
const disableThreading = useSettingsStore((state) => state.disableThreading);
const threadGroups = useMemo(() => {
const groups = groupEmailsByThread(emails, disableThreading);
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView);
return sortThreadGroups(groups);
}, [emails, disableThreading]);
}, [emails, disableThreading, isScheduledView]);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
@@ -108,6 +122,8 @@ export function EmailList({
const density = useSettingsStore((state) => state.density);
const showPreview = useSettingsStore((state) => state.showPreview);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const footerHasMore = hasMore ?? hasMoreEmails;
const footerIsLoadingMore = isLoadingMoreItems ?? isLoadingMore;
const isMobile = useUIStore((state) => state.isMobile);
// Match the list items: focus layout collapses to multi-line on mobile, so virtualizer estimates must match.
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
@@ -219,10 +235,14 @@ export function EmailList({
};
const handleLoadMore = useCallback(() => {
if (isScheduledView) {
onLoadMoreScheduled?.();
return;
}
if (client && hasMoreEmails && !isLoadingMore && !isLoading) {
loadMoreEmails(client);
}
}, [client, hasMoreEmails, isLoadingMore, isLoading, loadMoreEmails]);
}, [client, hasMoreEmails, isLoadingMore, isLoading, isScheduledView, loadMoreEmails, onLoadMoreScheduled]);
const handleToggleThreadExpansion = useCallback(async (threadId: string) => {
const isExpanded = expandedThreadIds.has(threadId);
@@ -282,7 +302,7 @@ export function EmailList({
<div
className={cn(
"transition-all duration-300 ease-in-out overflow-hidden",
hasSelection ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
hasSelection && !isScheduledView ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
)}
>
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
@@ -406,17 +426,19 @@ export function EmailList({
) : emails.length === 0 && !isLoading ? (
<div className="flex flex-col items-center justify-center h-full py-12">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-muted shadow-lg flex items-center justify-center">
{searchQuery || !isFilterEmpty(searchFilters) ? (
{isScheduledView ? (
<CalendarClock className="w-10 h-10 text-muted-foreground" />
) : searchQuery || !isFilterEmpty(searchFilters) ? (
<SearchX className="w-10 h-10 text-muted-foreground" />
) : (
<MailX className="w-10 h-10 text-muted-foreground" />
)}
</div>
<p className="text-base font-medium text-foreground">
{searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results') : t('no_emails')}
{isScheduledView ? t('no_scheduled_emails') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results') : t('no_emails')}
</p>
<p className="text-sm mt-1 text-muted-foreground">
{searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')}
{isScheduledView ? t('no_scheduled_emails_description') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')}
</p>
</div>
) : (
@@ -468,13 +490,13 @@ export function EmailList({
</div>
<div className="py-4 flex justify-center">
{isLoadingMore && hasMoreEmails && (
{footerIsLoadingMore && footerHasMore && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<span>{t('loading_more')}</span>
</div>
)}
{!hasMoreEmails && emails.length > 0 && (
{!footerHasMore && emails.length > 0 && (
<div className="text-sm text-muted-foreground border-t border-border pt-6">
{t('no_more_emails')}
</div>
@@ -509,6 +531,9 @@ export function EmailList({
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
onEditDraft={() => onEditDraft?.(contextMenu.data!)}
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenu.data!) : undefined}
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenu.data!) : undefined}
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenu.data!) : undefined}
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
onBatchDelete={() => client && batchDelete(client)}
onBatchArchive={async () => {
+100 -6
View File
@@ -66,6 +66,7 @@ import {
EditIcon,
PlayCircle,
PenSquare,
CalendarClock,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
@@ -126,6 +127,9 @@ interface EmailViewerProps {
onNavigatePrev?: () => void;
onShowShortcuts?: () => void;
onEditDraft?: () => void;
onCancelScheduled?: () => void;
onCancelScheduledForEdit?: () => void;
onRescheduleScheduled?: (delayedUntil: string) => void;
onCompose?: () => void;
currentUserEmail?: string;
currentUserName?: string;
@@ -875,6 +879,9 @@ export function EmailViewer({
onNavigatePrev,
onShowShortcuts,
onEditDraft,
onCancelScheduled,
onCancelScheduledForEdit,
onRescheduleScheduled,
onCompose,
currentUserEmail,
currentUserName,
@@ -884,6 +891,7 @@ export function EmailViewer({
className,
}: EmailViewerProps) {
const t = useTranslations('email_viewer');
const tComposer = useTranslations('email_composer');
const tNotifications = useTranslations('notifications');
const tCommon = useTranslations('common');
const tSmime = useTranslations('smime');
@@ -934,6 +942,8 @@ export function EmailViewer({
// Detect if the email is a draft
const isDraft = email?.keywords?.['$draft'] === true;
const isScheduled = email?.isScheduled === true;
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
// Color options for email tags (from user-defined keyword settings)
const colorOptions = emailKeywords.map((kw) => ({
@@ -947,6 +957,29 @@ export function EmailViewer({
const { tabletListVisible } = useUIStore();
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
const value = window.prompt(t('reschedule_prompt'));
if (!value) return null;
const time = new Date(value).getTime();
if (!Number.isFinite(time)) {
toast.error(tComposer('schedule_send_invalid'));
return null;
}
if (time <= Date.now()) {
toast.error(tComposer('schedule_send_future'));
return null;
}
if (!client?.hasDelayedSend()) {
toast.error(tComposer('schedule_send_unsupported'));
return null;
}
const maxDelayedSend = client.getMaxDelayedSend();
if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) {
toast.error(tComposer('schedule_send_too_late'));
return null;
}
return new Date(time).toISOString();
}, [client, t, tComposer]);
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const { startTour } = useTour();
const isEmbedded = useIsEmbedded();
@@ -3358,7 +3391,32 @@ export function EmailViewer({
<ChevronLeft className="w-5 h-5" />
</Button>
)}
{isDraft && onEditDraft && (
{isScheduled && canCancelScheduled && (
<>
<Button variant="default" size="sm" onClick={onCancelScheduled} className="sm:flex sm:h-8" title={t('cancel_scheduled_send')}>
<X className="w-4 h-4" />
<span className="hidden sm:inline text-sm">{t('cancel_scheduled_send')}</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
const delayedUntil = promptForRescheduleDelayedUntil();
if (delayedUntil) onRescheduleScheduled?.(delayedUntil);
}}
className="hidden sm:flex sm:h-8"
title={t('reschedule_send')}
>
<CalendarClock className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('reschedule_send')}</span>}
</Button>
<Button variant="ghost" size="sm" onClick={onCancelScheduledForEdit} className="hidden sm:flex sm:h-8" title={email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}>
<EditIcon className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}</span>}
</Button>
</>
)}
{!isScheduled && isDraft && onEditDraft && (
<Button
variant="default"
size="sm"
@@ -3370,7 +3428,7 @@ export function EmailViewer({
<span className="text-sm">{t('edit_draft')}</span>
</Button>
)}
{!isDraft && (<>
{!isScheduled && !isDraft && (<>
<Button
variant="ghost"
size="sm"
@@ -3412,6 +3470,7 @@ export function EmailViewer({
</div>
{/* Right: Organize actions - order: archive, delete, move, tag, spam, read state, print, view source */}
{!isScheduled && (
<div className="flex items-center gap-0 sm:gap-0.5">
{/* Archive */}
<Button
@@ -3869,6 +3928,7 @@ export function EmailViewer({
)}
</div>
</div>
)}
</>
);
@@ -3879,13 +3939,13 @@ export function EmailViewer({
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden animate-in fade-in duration-300 relative", className)}
>
{/* Mobile More menu sidebar overlay */}
{isMobile && moreMenuOpen && (
{!isScheduled && isMobile && moreMenuOpen && (
<div
className="fixed inset-0 bg-black/50 z-[60] sm:hidden"
onClick={() => setMoreMenuOpen(false)}
/>
)}
{isMobile && (
{!isScheduled && isMobile && (
<div className={cn(
"fixed inset-y-0 right-0 w-72 bg-background border-l border-border z-[70] sm:hidden",
"transform transition-transform duration-300 ease-in-out",
@@ -4817,10 +4877,44 @@ export function EmailViewer({
</div>
)}
{/* Scheduled Banner */}
{isScheduled && (
<div className="border-b border-border bg-primary/10">
<div className="max-w-4xl mx-auto px-6 py-2.5 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-primary">
<CalendarClock className="w-4 h-4" />
<span className="text-sm font-medium">
{t('scheduled_banner', { date: email.scheduledSendAt ? formatDateTime(email.scheduledSendAt, timeFormat) : '' })}
</span>
</div>
<div className="flex items-center gap-2">
{canCancelScheduled && (
<>
<Button size="sm" variant="outline" onClick={onCancelScheduled}>{t('cancel_scheduled_send')}</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const delayedUntil = promptForRescheduleDelayedUntil();
if (delayedUntil) onRescheduleScheduled?.(delayedUntil);
}}
>
{t('reschedule_send')}
</Button>
<Button size="sm" variant="outline" onClick={onCancelScheduledForEdit}>
{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}
</Button>
</>
)}
</div>
</div>
</div>
)}
{/* Draft Banner */}
{isDraft && (
<div className="border-b border-border bg-warning/10">
<div className="max-w-4xl mx-auto px-6 py-2.5 flex items-center justify-between">
<div className="px-6 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-2 text-warning">
<File className="w-4 h-4" />
<span className="text-sm font-medium">{t('draft_banner')}</span>
@@ -5289,7 +5383,7 @@ export function EmailViewer({
<PluginSlot name="email-footer" />
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6 mt-auto" style={{ paddingBlock: 'var(--density-header-py)' }}>
{!isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className="bg-background border-t border-border px-6 mt-auto" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
<div className="flex-shrink-0">
<Avatar
+102 -50
View File
@@ -1,11 +1,11 @@
"use client";
import React, { useCallback } from "react";
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
import { formatDate, formatDateTime, stripInvisibleLeading } 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, Reply, Forward } from "lucide-react";
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock } from "lucide-react";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store";
@@ -67,6 +67,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
@@ -78,6 +79,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
const scheduledSendLabel = email.isScheduled && email.scheduledSendAt
? formatDateTime(email.scheduledSendAt, timeFormat)
: null;
// Resolve color tags using keyword definitions; unknown tags fall back to gray
const tagIds = getEmailColorTags(email.keywords);
@@ -241,12 +245,22 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{resolvedKeywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))}
<span className={cn(
'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(email.receivedAt)}
</span>
{scheduledSendLabel ? (
<span
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300"
title={scheduledSendLabel}
>
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="truncate">{scheduledSendLabel}</span>
</span>
) : (
<span className={cn(
'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(email.receivedAt)}
</span>
)}
</div>
</div>
) : (
@@ -299,14 +313,24 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{kd.label}
</span>
))}
<span className={cn(
"text-xs tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
{scheduledSendLabel ? (
<span
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium tabular-nums text-sky-700 dark:text-sky-300"
title={scheduledSendLabel}
>
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="truncate">{scheduledSendLabel}</span>
</span>
) : (
<span className={cn(
"text-xs tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
)}
</div>
</div>
@@ -335,16 +359,18 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
{/* Hover Quick Actions */}
<EmailHoverActions
email={email}
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
onArchive={onArchive}
onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam}
/>
{!email.isScheduled && (
<EmailHoverActions
email={email}
backgroundClassName={resolvedColorTag ? resolvedColorTag : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
onArchive={onArchive}
onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam}
/>
)}
</div>
);
}
@@ -374,6 +400,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
@@ -381,6 +408,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
const scheduledSendLabel = latestEmail.isScheduled && latestEmail.scheduledSendAt
? formatDateTime(latestEmail.scheduledSendAt, timeFormat)
: null;
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
const getAccountById = useAccountStore((state) => state.getAccountById);
@@ -646,12 +676,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{keywordDef && (
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
)}
<span className={cn(
'text-xs tabular-nums',
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(latestEmail.receivedAt)}
</span>
{scheduledSendLabel ? (
<span
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300"
title={scheduledSendLabel}
>
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="truncate">{scheduledSendLabel}</span>
</span>
) : (
<span className={cn(
'text-xs tabular-nums',
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(latestEmail.receivedAt)}
</span>
)}
</div>
</div>
) : (
@@ -716,14 +756,24 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{keywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
hasUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(latestEmail.receivedAt)}
</span>
{scheduledSendLabel ? (
<span
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium tabular-nums text-sky-700 dark:text-sky-300"
title={scheduledSendLabel}
>
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="truncate">{scheduledSendLabel}</span>
</span>
) : (
<span className={cn(
"text-xs tabular-nums",
hasUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(latestEmail.receivedAt)}
</span>
)}
</div>
</div>
@@ -752,16 +802,18 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div>
{/* Hover Quick Actions for thread header */}
<EmailHoverActions
email={latestEmail}
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
/>
{!latestEmail.isScheduled && (
<EmailHoverActions
email={latestEmail}
backgroundClassName={colorTag ? colorTag : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
/>
)}
</div>
{isExpanded && !isMobile && !isFocusedMailLayout && (
+60 -30
View File
@@ -74,6 +74,8 @@ interface SidebarProps {
onDeleteFolder?: (mailboxId: string) => void;
onImportEmail?: (mailboxId: string) => void;
onRefreshMailboxes?: () => void;
scheduledTotal?: number;
showScheduledMailbox?: boolean;
className?: string;
/**
* Multi-account (Pro) mode props. When `multiAccountMode` is true, the
@@ -674,6 +676,8 @@ export function Sidebar({
onDeleteFolder,
onImportEmail,
onRefreshMailboxes,
scheduledTotal = 0,
showScheduledMailbox = false,
className,
multiAccountMode = false,
accountMailboxes,
@@ -1027,22 +1031,35 @@ export function Sidebar({
{!isCollapsed && t("loading_mailboxes")}
</div>
) : (
tree.map((node) => (
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedKeyword || !isViewing ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={(mailboxId) =>
onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId)
}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined}
colorful={colorfulSidebarIcons}
onContextMenu={isActive ? handleMailboxContextMenu : undefined}
/>
))
<>
{tree.map((node) => (
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedKeyword || !isViewing ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={(mailboxId) =>
onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId)
}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined}
colorful={colorfulSidebarIcons}
onContextMenu={isActive ? handleMailboxContextMenu : undefined}
/>
))}
{isActive && showScheduledMailbox && (
<SidebarRow
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
label={t('scheduled')}
depth={0}
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
total={scheduledTotal}
onClick={() => onMailboxSelect?.('__scheduled__')}
isCollapsed={isCollapsed}
/>
)}
</>
)}
</>
)}
@@ -1067,20 +1084,33 @@ export function Sidebar({
{!isCollapsed && t("loading_mailboxes")}
</div>
) : (
ownTree.map((node) => (
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/>
))
<>
{ownTree.map((node) => (
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/>
))}
{showScheduledMailbox && (
<SidebarRow
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
label={t('scheduled')}
depth={0}
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
total={scheduledTotal}
onClick={() => onMailboxSelect?.('__scheduled__')}
isCollapsed={isCollapsed}
/>
)}
</>
)}
</>
)}
+20 -2
View File
@@ -26,7 +26,10 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
const client = useAuthStore((s) => s.client);
const sendEmail = useEmailStore((s) => s.sendEmail);
const fetchEmails = useEmailStore((s) => s.fetchEmails);
const fetchScheduledEmails = useEmailStore((s) => s.fetchScheduledEmails);
const refreshScheduledMetadata = useEmailStore((s) => s.refreshScheduledMetadata);
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
const isScheduledView = useEmailStore((s) => s.isScheduledView);
const closeTab = useProTabStore((s) => s.closeTab);
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
const updateComposeDraft = useProTabStore((s) => s.updateComposeDraft);
@@ -36,10 +39,18 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
const tabIdRef = useRef(tabId);
tabIdRef.current = tabId;
const handleScheduledSendCreated = useCallback(async () => {
if (client) {
await refreshScheduledMetadata(client);
if (isScheduledView) await fetchScheduledEmails(client);
}
closeTab(tabIdRef.current);
}, [client, refreshScheduledMetadata, isScheduledView, fetchScheduledEmails, closeTab]);
const handleSend = useCallback(async (sendData: Parameters<NonNullable<React.ComponentProps<typeof EmailComposer>['onSend']>>[0]) => {
if (!client) return;
try {
await sendEmail(
const result = await sendEmail(
client,
sendData.to,
sendData.subject,
@@ -54,9 +65,15 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
sendData.attachments,
sendData.inReplyTo,
sendData.references,
sendData.delayedUntil,
sendData.envelopeMailFrom,
);
if (result.scheduled) {
await handleScheduledSendCreated();
return;
}
// Mark the original message as $answered / $forwarded so the standard
// viewer and list reflect the action (same behaviour as inline compose).
if (data.sourceEmailId && (data.mode === 'reply' || data.mode === 'replyAll')) {
@@ -81,7 +98,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
console.error('Failed to send email:', error);
toast.error(t('notifications.error_sending'));
}
}, [client, sendEmail, fetchEmails, selectedMailbox, closeTab, data.sourceEmailId, data.mode, t]);
}, [client, sendEmail, fetchEmails, selectedMailbox, closeTab, data.sourceEmailId, data.mode, t, handleScheduledSendCreated]);
const handleClose = useCallback(() => {
closeTab(tabIdRef.current);
@@ -125,6 +142,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
initialDraftText={data.initialDraftText}
initialData={data.initialData}
onSend={handleSend}
onScheduledSendCreated={handleScheduledSendCreated}
onClose={handleClose}
onDiscardDraft={handleDiscardDraft}
onSaveState={handleSaveState}
@@ -3,6 +3,8 @@
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import type { SendDelaySeconds } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { X } from 'lucide-react';
import {
@@ -22,11 +24,14 @@ export function ComposingSettings() {
autoSelectReplyIdentity,
attachmentReminderEnabled,
attachmentReminderKeywords,
sendDelaySeconds,
subAddressDelimiter,
signaturePosition,
signatureSeparatorEnabled,
updateSetting,
} = useSettingsStore();
const { client } = useAuthStore();
const delayedSendSupported = client?.hasDelayedSend() ?? false;
return (
<SettingsSection title={t('title')} description={t('description')}>
@@ -37,6 +42,24 @@ export function ComposingSettings() {
/>
</SettingItem>
<SettingItem label={t('send_delay.label')} description={t('send_delay.description')}>
<div className="flex flex-col items-end gap-1">
<Select
value={String(sendDelaySeconds)}
onChange={(value) => updateSetting('sendDelaySeconds', Number(value) as SendDelaySeconds)}
options={[
{ value: '0', label: t('send_delay.off') },
{ value: '10', label: t('send_delay.seconds', { seconds: 10 }) },
{ value: '30', label: t('send_delay.seconds', { seconds: 30 }) },
{ value: '60', label: t('send_delay.seconds', { seconds: 60 }) },
]}
/>
{sendDelaySeconds > 0 && !delayedSendSupported && (
<p className="max-w-64 text-right text-xs text-amber-600 dark:text-amber-400">{t('send_delay.unsupported')}</p>
)}
</div>
</SettingItem>
<SettingItem label={t('signature_position.label')} description={t('signature_position.description')}>
<Select
value={signaturePosition}
+25 -5
View File
@@ -7,7 +7,7 @@ import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-s
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
import type { NotificationSoundChoice } from '@/lib/notification-sound';
import { Button } from '@/components/ui/button';
import { CheckCircle2, Volume2, XCircle } from 'lucide-react';
import { CheckCircle2, Lock, Volume2, XCircle } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
@@ -40,16 +40,30 @@ export function NotificationSettings() {
updateSetting,
} = useSettingsStore();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const adminPushRelayUrl = usePolicyStore((s) => s.policy.pushRelayUrl);
const pushRelayLocked = usePolicyStore((s) => s.policy.pushRelayUrlLocked) === true;
const client = useAuthStore((s) => s.client);
const username = useAuthStore((s) => s.username);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const supported = typeof window !== 'undefined' && isWebPushSupported();
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL);
const adminUrl = (adminPushRelayUrl ?? '').trim();
const [relayUrl, setRelayUrl] = useState(adminUrl || DEFAULT_RELAY_BASE_URL);
const [pushStatus, setPushStatus] = useState<PushStatus>(
supported ? { kind: 'idle' } : { kind: 'unsupported' },
);
// Pull the admin-configured URL into local state when policy loads/changes.
// When locked, the admin value always wins; when only set (not locked), use
// it as the initial default but let the user override.
useEffect(() => {
if (pushRelayLocked && adminUrl) {
setRelayUrl(adminUrl);
} else if (adminUrl) {
setRelayUrl((current) => (current === DEFAULT_RELAY_BASE_URL ? adminUrl : current));
}
}, [adminUrl, pushRelayLocked]);
useEffect(() => {
if (!supported) return;
if (!client) return;
@@ -125,12 +139,17 @@ export function NotificationSettings() {
<SettingsSection title={t('push.title')} description={t('push.description')}>
<div className="rounded-md border p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<label className="text-sm font-medium" htmlFor="push-relay-url">
<label className="text-sm font-medium inline-flex items-center gap-1.5" htmlFor="push-relay-url">
{t('push.relay_label')}
{pushRelayLocked && (
<Lock className="w-3 h-3 text-muted-foreground" aria-label={t('push.relay_locked')} />
)}
</label>
<PushStatusBadge status={pushStatus} t={t} />
</div>
<p className="text-xs text-muted-foreground">{t('push.relay_desc')}</p>
<p className="text-xs text-muted-foreground">
{pushRelayLocked ? t('push.relay_locked_desc') : t('push.relay_desc')}
</p>
<input
id="push-relay-url"
type="url"
@@ -140,7 +159,8 @@ export function NotificationSettings() {
value={relayUrl}
onChange={(e) => setRelayUrl(e.target.value)}
placeholder={t('push.relay_placeholder')}
disabled={busy || pushStatus.kind === 'unsupported'}
disabled={busy || pushStatus.kind === 'unsupported' || pushRelayLocked}
readOnly={pushRelayLocked}
className="w-full rounded border bg-background px-3 py-2 text-sm disabled:opacity-50"
/>
<div className="flex flex-wrap gap-2">
+2
View File
@@ -289,6 +289,8 @@ export const KEYBOARD_SHORTCUTS = {
{ key: "x", description: "shortcuts.threads.expand_collapse" },
],
composer: [
{ key: "Ctrl + Enter", description: "shortcuts.composer.send" },
{ key: "Ctrl + Shift + Enter", description: "shortcuts.composer.schedule_send" },
{ key: "t", description: "shortcuts.composer.template_picker" },
{ key: "Ctrl/Cmd + Enter", description: "shortcuts.composer.send" },
],
+81 -2
View File
@@ -36,16 +36,29 @@ describe('config API route', () => {
delete process.env.LOGIN_IMPRINT_URL;
delete process.env.LOGIN_PRIVACY_POLICY_URL;
delete process.env.LOGIN_WEBSITE_URL;
delete process.env.DOMAIN_BRANDING;
});
afterEach(() => {
process.env = { ...originalEnv };
});
async function getConfig() {
function mockRequest(headers: Record<string, string> = {}): unknown {
const lc: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) lc[k.toLowerCase()] = v;
return {
headers: {
get(name: string) {
return lc[name.toLowerCase()] ?? null;
},
},
};
}
async function getConfig(headers?: Record<string, string>) {
// Re-import to pick up env changes
const { GET } = await import('@/app/api/config/route');
const response = await GET();
const response = await GET(mockRequest(headers) as Parameters<typeof GET>[0]);
return response.json();
}
@@ -192,4 +205,70 @@ describe('config API route', () => {
expect(config.appLogoLightUrl).toBe('/branding/my-logo.svg');
expect(config.appLogoDarkUrl).toBe('/branding/my-logo-white.svg');
});
describe('per-domain branding overrides', () => {
it('applies overrides for the matching host', async () => {
process.env.LOGIN_COMPANY_NAME = 'Default Co';
process.env.LOGIN_WEBSITE_URL = 'https://default.example';
process.env.DOMAIN_BRANDING = JSON.stringify([
{
host: 'mail1.example.com',
loginCompanyName: 'Brand One',
loginWebsiteUrl: 'https://one.example',
},
]);
const config = await getConfig({ host: 'mail1.example.com' });
expect(config.loginCompanyName).toBe('Brand One');
expect(config.loginWebsiteUrl).toBe('https://one.example');
});
it('falls through to the global value when the host has no entry', async () => {
process.env.LOGIN_COMPANY_NAME = 'Default Co';
process.env.DOMAIN_BRANDING = JSON.stringify([
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
]);
const config = await getConfig({ host: 'unmapped.example.com' });
expect(config.loginCompanyName).toBe('Default Co');
});
it('falls through field-by-field when the matching entry omits a field', async () => {
process.env.LOGIN_COMPANY_NAME = 'Default Co';
process.env.LOGIN_WEBSITE_URL = 'https://default.example';
process.env.DOMAIN_BRANDING = JSON.stringify([
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
]);
const config = await getConfig({ host: 'mail1.example.com' });
expect(config.loginCompanyName).toBe('Brand One');
expect(config.loginWebsiteUrl).toBe('https://default.example');
});
it('prefers X-Forwarded-Host over Host', async () => {
process.env.DOMAIN_BRANDING = JSON.stringify([
{ host: 'public.example.com', loginCompanyName: 'Public' },
]);
const config = await getConfig({
host: 'internal.example.com',
'x-forwarded-host': 'public.example.com',
});
expect(config.loginCompanyName).toBe('Public');
});
it('strips the port from the host header before matching', async () => {
process.env.DOMAIN_BRANDING = JSON.stringify([
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
]);
const config = await getConfig({ host: 'mail1.example.com:8443' });
expect(config.loginCompanyName).toBe('Brand One');
});
});
});
+151
View File
@@ -0,0 +1,151 @@
import { describe, it, expect } from 'vitest';
import {
matchDomainBranding,
parseDomainBranding,
pickRequestHost,
type DomainBrandingEntry,
} from '@/lib/admin/domain-branding';
function mockHeaders(map: Record<string, string>): Headers {
const lc: Record<string, string> = {};
for (const [k, v] of Object.entries(map)) lc[k.toLowerCase()] = v;
return {
get(name: string) {
return lc[name.toLowerCase()] ?? null;
},
} as unknown as Headers;
}
describe('parseDomainBranding', () => {
it('returns [] for null/undefined/empty', () => {
expect(parseDomainBranding(null)).toEqual([]);
expect(parseDomainBranding(undefined)).toEqual([]);
expect(parseDomainBranding('')).toEqual([]);
expect(parseDomainBranding([])).toEqual([]);
});
it('parses a stringified JSON array', () => {
const raw = JSON.stringify([{ host: 'mail.example.com', loginCompanyName: 'Acme' }]);
expect(parseDomainBranding(raw)).toEqual([
{ host: 'mail.example.com', loginCompanyName: 'Acme' },
]);
});
it('lower-cases hosts and strips trailing dots', () => {
expect(parseDomainBranding([{ host: 'Mail.Example.COM.' }])).toEqual([
{ host: 'mail.example.com' },
]);
});
it('accepts wildcard hosts', () => {
expect(parseDomainBranding([{ host: '*.example.com', loginCompanyName: 'Wild' }])).toEqual([
{ host: '*.example.com', loginCompanyName: 'Wild' },
]);
});
it('drops entries with invalid hosts', () => {
const out = parseDomainBranding([
{ host: '' },
{ host: 'has space.com' },
{ host: 'foo..bar' },
{ host: '*.*.example.com' }, // embedded wildcard not allowed
{ host: 'good.example.com' },
]);
expect(out.map(e => e.host)).toEqual(['good.example.com']);
});
it('drops duplicate hosts, keeping the first', () => {
const out = parseDomainBranding([
{ host: 'foo.com', loginCompanyName: 'First' },
{ host: 'FOO.com', loginCompanyName: 'Second' },
]);
expect(out).toEqual([{ host: 'foo.com', loginCompanyName: 'First' }]);
});
it('ignores non-string and empty-string override fields', () => {
const out = parseDomainBranding([
{
host: 'foo.com',
loginCompanyName: '',
loginImprintUrl: 42,
loginWebsiteUrl: 'https://foo.com',
},
]);
expect(out).toEqual([{ host: 'foo.com', loginWebsiteUrl: 'https://foo.com' }]);
});
it('ignores unknown fields', () => {
const out = parseDomainBranding([
{ host: 'foo.com', notARealField: 'x', loginCompanyName: 'OK' },
]);
expect(out).toEqual([{ host: 'foo.com', loginCompanyName: 'OK' }]);
});
});
describe('pickRequestHost', () => {
it('returns null when no host headers are set', () => {
expect(pickRequestHost(mockHeaders({}))).toBeNull();
});
it('prefers X-Forwarded-Host over Host', () => {
expect(pickRequestHost(mockHeaders({
'x-forwarded-host': 'forwarded.example.com',
host: 'origin.example.com',
}))).toBe('forwarded.example.com');
});
it('falls back to Host when X-Forwarded-Host is absent', () => {
expect(pickRequestHost(mockHeaders({ host: 'origin.example.com' }))).toBe('origin.example.com');
});
it('strips the port', () => {
expect(pickRequestHost(mockHeaders({ host: 'example.com:8080' }))).toBe('example.com');
});
it('takes the first entry of a comma-separated X-Forwarded-Host', () => {
expect(pickRequestHost(mockHeaders({
'x-forwarded-host': 'first.example.com, second.example.com',
}))).toBe('first.example.com');
});
it('lower-cases the result', () => {
expect(pickRequestHost(mockHeaders({ host: 'EXAMPLE.com' }))).toBe('example.com');
});
});
describe('matchDomainBranding', () => {
const entries: DomainBrandingEntry[] = [
{ host: 'mail.example.com', loginCompanyName: 'Exact' },
{ host: '*.example.com', loginCompanyName: 'Wildcard' },
{ host: '*.dev.example.com', loginCompanyName: 'Specific Wildcard' },
{ host: 'other.com', loginCompanyName: 'Other' },
];
it('returns {} when host is null', () => {
expect(matchDomainBranding(null, entries)).toEqual({});
});
it('returns {} when no entries match', () => {
expect(matchDomainBranding('unknown.org', entries)).toEqual({});
});
it('prefers exact match over wildcard', () => {
expect(matchDomainBranding('mail.example.com', entries).loginCompanyName).toBe('Exact');
});
it('matches wildcards on subdomains', () => {
expect(matchDomainBranding('foo.example.com', entries).loginCompanyName).toBe('Wildcard');
});
it('prefers the longest wildcard suffix', () => {
expect(matchDomainBranding('app.dev.example.com', entries).loginCompanyName).toBe('Specific Wildcard');
});
it('does not match the wildcard host against the apex domain', () => {
expect(matchDomainBranding('example.com', entries)).toEqual({});
});
it('is case-insensitive on the request host', () => {
expect(matchDomainBranding('Mail.Example.COM', entries).loginCompanyName).toBe('Exact');
});
});
+97 -1
View File
@@ -11,6 +11,34 @@ function createClient(): JMAPClient {
return client;
}
function enableDelayedSend(client: JMAPClient) {
Object.assign(client, {
capabilities: {
'urn:ietf:params:jmap:core': {},
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:submission': {},
},
session: {
primaryAccounts: {
'urn:ietf:params:jmap:mail': 'account-1',
'urn:ietf:params:jmap:submission': 'submission-account-1',
},
accounts: {
'account-1': {
accountCapabilities: {
'urn:ietf:params:jmap:mail': {},
},
},
'submission-account-1': {
accountCapabilities: {
'urn:ietf:params:jmap:submission': { maxDelayedSend: 3600, submissionExtensions: { FUTURERELEASE: true } },
},
},
},
},
});
}
interface JMAPMethodCall {
0: string;
1: Record<string, unknown>;
@@ -62,7 +90,7 @@ function mockSendEmailFlow() {
payload = {
methodResponses: [
['Email/set', { created: { [Object.keys((captured[callIdx].methodCalls[0][1] as { create: Record<string, unknown> }).create)[0]]: { id: 'sent-id-1' } } }, '0'],
['EmailSubmission/set', { created: { '1': { id: 'sub-1' } } }, '1'],
['EmailSubmission/set', { created: { '1': { id: 'sub-1', sendAt: '2026-05-08T18:00:00Z' } } }, '1'],
],
};
}
@@ -167,4 +195,72 @@ describe('JMAPClient.sendEmail threading headers', () => {
expect(draft.inReplyTo).toEqual(['real@example.com']);
expect(draft.references).toBeUndefined();
});
it('uses FUTURERELEASE envelope and submission capability for scheduled sends', async () => {
const client = createClient();
enableDelayedSend(client);
const captured = mockSendEmailFlow();
const delayedUntil = new Date(Date.now() + 60_000).toISOString();
const result = await client.sendEmail(
['recipient@example.com'],
'Scheduled test',
'body',
undefined, undefined, 'identity-1', 'user@example.com',
undefined, undefined, undefined, undefined,
undefined,
undefined,
delayedUntil,
);
const identityRequest = captured[1];
expect(identityRequest.using).toContain('urn:ietf:params:jmap:submission');
const submissionCall = captured[2].methodCalls.find(call => call[0] === 'EmailSubmission/set');
expect(submissionCall?.[1].accountId).toBe('submission-account-1');
expect(submissionCall?.[1].create).toEqual({
'1': {
emailId: expect.stringMatching(/^#send-/),
identityId: 'identity-1',
envelope: {
mailFrom: {
email: 'user@example.com',
parameters: { HOLDFOR: expect.stringMatching(/^\d+$/) },
},
rcptTo: [{ email: 'recipient@example.com' }],
},
},
});
expect(JSON.stringify(submissionCall?.[1].create)).not.toContain('sendAt');
expect(result).toMatchObject({ scheduled: true, emailSubmissionId: 'sub-1', sendAt: '2026-05-08T18:00:00Z' });
});
it('cleans up replacement submission if canceling the original fails during reschedule', async () => {
const client = createClient();
enableDelayedSend(client);
vi.spyOn(client, 'getMailboxes').mockResolvedValue([
{ id: 'mb-drafts', name: 'Drafts', role: 'drafts' },
{ id: 'mb-sent', name: 'Sent', role: 'sent' },
] as never);
vi.spyOn(client, 'getIdentities').mockResolvedValue([
{ id: 'identity-1', name: 'User', email: 'user@example.com', mayDelete: false },
]);
const requestSpy = vi.spyOn(client as unknown as { request: JMAPClient['request'] }, 'request')
.mockImplementation(async (methodCalls) => {
const args = methodCalls[0][1] as { create?: unknown; update?: Record<string, unknown> };
if (args.create) {
return { methodResponses: [['EmailSubmission/set', { created: { replacement: { id: 'sub-new' } } }, '0']] };
}
if (args.update?.['sub-old']) {
return { methodResponses: [['EmailSubmission/set', { notUpdated: { 'sub-old': { type: 'cannotUnsend' } } }, '0']] };
}
return { methodResponses: [['EmailSubmission/set', { updated: { 'sub-new': null } }, '0']] };
});
await expect(client.rescheduleEmailSubmission('sub-old', 'email-1', 'identity-1', new Date(Date.now() + 60_000).toISOString()))
.rejects.toThrow('could not cancel the original');
expect(requestSpy).toHaveBeenCalledWith(expect.arrayContaining([
expect.arrayContaining(['EmailSubmission/set', expect.objectContaining({ update: { 'sub-new': { undoStatus: 'canceled' } } })]),
]));
});
});
+144
View File
@@ -0,0 +1,144 @@
/**
* Per-domain branding overrides: schema, parsing, host extraction, and match.
*
* The webmail can be served on multiple hostnames (e.g. mail1.example.com,
* mail2.other.com). Each hostname can override a subset of branding fields;
* unset fields fall back to the global admin/env/default value.
*/
import type { NextRequest } from 'next/server';
/** Config keys that can be overridden per domain. */
export const BRANDING_OVERRIDE_KEYS = [
'appName',
'appShortName',
'appDescription',
'faviconUrl',
'pwaIconUrl',
'pwaThemeColor',
'pwaBackgroundColor',
'appLogoLightUrl',
'appLogoDarkUrl',
'loginLogoLightUrl',
'loginLogoDarkUrl',
'loginCompanyName',
'loginImprintUrl',
'loginPrivacyPolicyUrl',
'loginWebsiteUrl',
] as const;
export type BrandingOverrideKey = (typeof BRANDING_OVERRIDE_KEYS)[number];
export interface DomainBrandingEntry {
/**
* Hostname this entry applies to. Either an exact host like
* "mail.example.com" or a wildcard like "*.example.com" (matches any
* direct or deeper subdomain). Case-insensitive; trailing dots are
* stripped on parse.
*/
host: string;
appName?: string;
appShortName?: string;
appDescription?: string;
faviconUrl?: string;
pwaIconUrl?: string;
pwaThemeColor?: string;
pwaBackgroundColor?: string;
appLogoLightUrl?: string;
appLogoDarkUrl?: string;
loginLogoLightUrl?: string;
loginLogoDarkUrl?: string;
loginCompanyName?: string;
loginImprintUrl?: string;
loginPrivacyPolicyUrl?: string;
loginWebsiteUrl?: string;
}
// Accepts plain hostnames (foo, foo.bar, foo.bar.baz) and one-level wildcards
// at the leftmost label (*.example.com). Rejects IPs, scheme/path/userinfo,
// and embedded wildcards.
const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
function normalizeHost(host: string): string {
return host.trim().toLowerCase().replace(/\.+$/, '');
}
/** Parse the raw config value (array of entries, or string-JSON). Invalid entries are dropped. */
export function parseDomainBranding(raw: unknown): DomainBrandingEntry[] {
if (!raw) return [];
let value = raw;
if (typeof value === 'string') {
if (!value.trim()) return [];
try {
value = JSON.parse(value);
} catch {
return [];
}
}
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const out: DomainBrandingEntry[] = [];
for (const item of value) {
if (!item || typeof item !== 'object') continue;
const rec = item as Record<string, unknown>;
const rawHost = typeof rec.host === 'string' ? rec.host : '';
const host = normalizeHost(rawHost);
if (!host || !HOST_RE.test(host)) continue;
if (seen.has(host)) continue;
seen.add(host);
const entry: DomainBrandingEntry = { host };
const writable = entry as unknown as Record<string, string>;
for (const key of BRANDING_OVERRIDE_KEYS) {
const v = rec[key];
if (typeof v === 'string' && v.length > 0) {
writable[key] = v;
}
}
out.push(entry);
}
return out;
}
type HeadersLike = Headers | { get(name: string): string | null };
/**
* Pick the request's host, preferring X-Forwarded-Host (first entry if
* comma-separated) over Host. Strips the port. Returns null when no usable
* host header is set.
*/
export function pickRequestHost(headersOrReq: NextRequest | HeadersLike): string | null {
const headers: HeadersLike = 'headers' in headersOrReq ? (headersOrReq as NextRequest).headers : headersOrReq;
const raw = headers.get('x-forwarded-host') || headers.get('host');
if (!raw) return null;
const first = raw.split(',')[0]?.trim();
if (!first) return null;
return normalizeHost(first.split(':')[0]);
}
/**
* Find the entry whose host matches `host`. Exact match always wins; among
* wildcards the longest (most-specific) suffix wins. Returns {} when no
* entry matches.
*/
export function matchDomainBranding(
host: string | null,
entries: DomainBrandingEntry[],
): Partial<DomainBrandingEntry> {
if (!host || entries.length === 0) return {};
const lower = normalizeHost(host);
let wildcardMatch: DomainBrandingEntry | undefined;
for (const entry of entries) {
if (entry.host === lower) return entry;
if (entry.host.startsWith('*.')) {
const suffix = entry.host.slice(1); // ".example.com"
if (lower.endsWith(suffix) && lower.length > suffix.length) {
if (!wildcardMatch || entry.host.length > wildcardMatch.host.length) {
wildcardMatch = entry;
}
}
}
}
return wildcardMatch ?? {};
}
+7
View File
@@ -106,6 +106,10 @@ export interface SettingsPolicy {
approvedPlugins: string[];
/** Theme IDs that are force-enabled (users cannot deactivate) */
forceEnabledThemes: string[];
/** Web Push relay base URL shown to users. Empty means the built-in default. */
pushRelayUrl?: string;
/** When true, users cannot change pushRelayUrl in notification settings. */
pushRelayUrlLocked?: boolean;
}
export const DEFAULT_POLICY: SettingsPolicy = {
@@ -116,6 +120,8 @@ export const DEFAULT_POLICY: SettingsPolicy = {
forceEnabledPlugins: [],
approvedPlugins: [],
forceEnabledThemes: [],
pushRelayUrl: '',
pushRelayUrlLocked: false,
};
export interface AuditEntry {
@@ -157,6 +163,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
jmapServers: { envVar: 'JMAP_SERVERS', type: 'json', defaultValue: [] },
jmapServerAutoPickByDomain: { envVar: 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', type: 'boolean', defaultValue: false },
domainBranding: { envVar: 'DOMAIN_BRANDING', type: 'json', defaultValue: [] },
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
+98 -8
View File
@@ -1,5 +1,5 @@
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from '@/lib/jmap/types';
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult } from '@/lib/jmap/types';
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
import { getDemoData, type DemoData } from './demo-data';
import { generateDemoId } from './demo-utils';
@@ -11,6 +11,7 @@ import { generateDemoId } from './demo-utils';
export class DemoJMAPClient implements IJMAPClient {
private data: DemoData;
private blobStore = new Map<string, Blob>();
private scheduledSubmissions = new Map<string, { id: string; emailId: string; identityId: string; sendAt: string; undoStatus: 'pending' | 'final' | 'canceled'; isSmime: boolean }>();
private connectionCallback: ((connected: boolean) => void) | null = null;
private stateChangeCallback: ((change: StateChange) => void) | null = null;
private lastStates: AccountStates = {};
@@ -49,15 +50,15 @@ export class DemoJMAPClient implements IJMAPClient {
// ── Capabilities ──────────────────────────────────────────────
hasAccountCapability(_capability: string, _accountId?: string): boolean {
return false;
hasAccountCapability(capability: string, _accountId?: string): boolean {
return capability === 'urn:ietf:params:jmap:submission';
}
getCapabilities(): Record<string, unknown> {
return {
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 },
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:submission': {},
'urn:ietf:params:jmap:submission': { maxDelayedSend: 30 * 24 * 60 * 60, submissionExtensions: { FUTURERELEASE: true } },
'urn:ietf:params:jmap:vacationresponse': {},
'urn:ietf:params:jmap:contacts': {},
'urn:ietf:params:jmap:calendars': {},
@@ -70,6 +71,8 @@ export class DemoJMAPClient implements IJMAPClient {
getMaxSizeUpload(): number { return 50_000_000; }
getMaxCallsInRequest(): number { return 16; }
getMaxObjectsInGet(): number { return 500; }
getMaxDelayedSend(): number { return 30 * 24 * 60 * 60; }
hasDelayedSend(): boolean { return true; }
getEventSourceUrl(): string | null { return null; }
supportsEmailSubmission(): boolean { return true; }
supportsQuota(): boolean { return true; }
@@ -293,6 +296,8 @@ export class DemoJMAPClient implements IJMAPClient {
emails: Array<{ id: string; receivedAt: string }>,
archiveMailboxId: string,
mode: 'single' | 'year' | 'month',
_existingMailboxes: Mailbox[],
_accountId?: string,
): Promise<void> {
if (emails.length === 0) return;
if (mode === 'single') {
@@ -456,7 +461,9 @@ export class DemoJMAPClient implements IJMAPClient {
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[],
references?: string[],
): Promise<void> {
delayedUntil?: string,
_envelopeMailFrom?: string,
): Promise<SendEmailResult> {
// Remove draft if updating
if (draftId) {
this.data.emails = this.data.emails.filter(e => e.id !== draftId);
@@ -464,8 +471,8 @@ export class DemoJMAPClient implements IJMAPClient {
const sentMb = this.data.mailboxes.find(m => m.role === 'sent');
const email: Email = {
id: generateDemoId('email'), threadId: generateDemoId('thread'),
mailboxIds: { [sentMb?.id || 'demo-mailbox-sent']: true },
keywords: { $seen: true },
mailboxIds: { [delayedUntil ? (this.data.mailboxes.find(m => m.role === 'drafts')?.id || 'demo-mailbox-drafts') : (sentMb?.id || 'demo-mailbox-sent')]: true },
keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true },
size: body.length + (htmlBody?.length || 0),
receivedAt: new Date().toISOString(),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
@@ -485,7 +492,22 @@ export class DemoJMAPClient implements IJMAPClient {
references: references?.length ? references : undefined,
};
this.data.emails.push(email);
let emailSubmissionId: string | undefined;
if (delayedUntil) {
emailSubmissionId = generateDemoId('submission');
this.scheduledSubmissions.set(emailSubmissionId, {
id: emailSubmissionId,
emailId: email.id,
identityId: _identityId || 'demo-identity',
sendAt: delayedUntil,
undoStatus: 'pending',
isSmime: false,
});
}
this.recalcMailboxCounts();
return delayedUntil
? { scheduled: true, emailId: email.id, emailSubmissionId, sendAt: delayedUntil }
: { scheduled: false, emailId: email.id };
}
async sendImipReply(): Promise<void> { /* no-op in demo */ }
@@ -918,7 +940,75 @@ export class DemoJMAPClient implements IJMAPClient {
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
async submitEmail(): Promise<void> { /* no-op */ }
async sendRawEmail(): Promise<void> { /* no-op */ }
async sendRawEmail(_blob?: Blob, identityId = 'demo-identity', _sentMailboxId?: string, _draftMailboxId?: string, delayedUntil?: string, _envelopeRecipients?: string[]): Promise<SendEmailResult> {
const emailId = generateDemoId('email');
const draftsMailbox = this.data.mailboxes.find(m => m.role === 'drafts');
const sentMailbox = this.data.mailboxes.find(m => m.role === 'sent');
const email: Email = {
id: emailId,
threadId: generateDemoId('thread'),
mailboxIds: { [(delayedUntil ? draftsMailbox?.id : sentMailbox?.id) || 'demo-mailbox-sent']: true },
keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true },
size: 1024,
receivedAt: new Date().toISOString(),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
to: [],
subject: 'S/MIME message',
preview: 'Signed/encrypted demo message',
hasAttachment: false,
};
this.data.emails.push(email);
let emailSubmissionId: string | undefined;
if (delayedUntil) {
emailSubmissionId = generateDemoId('submission');
this.scheduledSubmissions.set(emailSubmissionId, { id: emailSubmissionId, emailId, identityId, sendAt: delayedUntil, undoStatus: 'pending', isSmime: true });
}
this.recalcMailboxCounts();
return delayedUntil ? { scheduled: true, emailId, emailSubmissionId, sendAt: delayedUntil, isSmime: true } : { scheduled: false, emailId, isSmime: true };
}
async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }> {
const pending = Array.from(this.scheduledSubmissions.values())
.filter(s => s.undoStatus === 'pending')
.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
const page = pending.slice(position, position + limit);
const emails = page.map((submission) => {
const email = this.data.emails.find(e => e.id === submission.emailId);
if (!email) return null;
return {
...email,
scheduledSendAt: submission.sendAt,
emailSubmissionId: submission.id,
scheduledIdentityId: submission.identityId,
scheduledUndoStatus: submission.undoStatus,
isScheduled: true,
isSmimeScheduled: submission.isSmime,
} satisfies ScheduledEmail;
}).filter((email): email is ScheduledEmail => email !== null);
const nextPosition = position + page.length;
return { emails, hasMore: nextPosition < pending.length, total: pending.length, nextPosition };
}
async cancelEmailSubmission(submissionId: string): Promise<void> {
const submission = this.scheduledSubmissions.get(submissionId);
if (submission) submission.undoStatus = 'canceled';
}
async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult> {
await this.cancelEmailSubmission(submissionId);
const replacement = generateDemoId('submission');
this.scheduledSubmissions.set(replacement, { id: replacement, emailId, identityId, sendAt: delayedUntil, undoStatus: 'pending', isSmime: false });
return { scheduled: true, emailId, emailSubmissionId: replacement, sendAt: delayedUntil };
}
async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
if (!email) return;
email.mailboxIds[draftMailboxId] = true;
if (sentMailboxId) delete email.mailboxIds[sentMailboxId];
email.keywords.$draft = true;
this.recalcMailboxCounts();
}
// ── Internal helpers ──────────────────────────────────────────
+11 -3
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal, PushSubscription } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal, PushSubscription, ScheduledEmail, SendEmailResult } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
/**
@@ -31,6 +31,8 @@ export interface IJMAPClient {
getMaxSizeUpload(): number;
getMaxCallsInRequest(): number;
getMaxObjectsInGet(): number;
getMaxDelayedSend(accountId?: string): number;
hasDelayedSend(accountId?: string): boolean;
getEventSourceUrl(): string | null;
supportsEmailSubmission(): boolean;
supportsQuota(): boolean;
@@ -146,8 +148,15 @@ export interface IJMAPClient {
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[],
references?: string[],
delayedUntil?: string,
envelopeMailFrom?: string,
): Promise<void>;
): Promise<SendEmailResult>;
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise<SendEmailResult>;
getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }>;
cancelEmailSubmission(submissionId: string): Promise<void>;
rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult>;
restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void>;
sendImipReply(opts: {
organizerEmail: string;
@@ -285,5 +294,4 @@ export interface IJMAPClient {
// ── S/MIME raw-email helpers ──────────────────────────────────
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): Promise<string>;
submitEmail(emailId: string, identityId: string): Promise<void>;
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string): Promise<void>;
}
+386 -18
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal, PushSubscription } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { IJMAPClient } from "./client-interface";
import { toWildcardQuery } from "./search-utils";
@@ -61,6 +61,12 @@ interface JMAPEmailHeader {
type JMAPMethodCall = [string, Record<string, unknown>, string];
const SUBMISSION_USING = [
'urn:ietf:params:jmap:core',
'urn:ietf:params:jmap:mail',
'urn:ietf:params:jmap:submission',
] as const;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type JMAPResponseResult = Record<string, any>;
@@ -312,6 +318,27 @@ function computeHasMore(position: number, emailCount: number, total: number, lim
return emailCount === limit;
}
function hasSubmissionMethod(methodCalls: JMAPMethodCall[]): boolean {
return methodCalls.some(([method]) => method.startsWith('Identity/') || method.startsWith('EmailSubmission/'));
}
function isSmimeEmail(email: Email): boolean {
const types: string[] = [];
const collect = (part: Email['bodyStructure']): void => {
if (!part) return;
if (part.type) types.push(part.type.toLowerCase());
part.subParts?.forEach(collect);
};
collect(email.bodyStructure);
email.attachments?.forEach(att => types.push((att.type || '').toLowerCase()));
return types.some(type =>
type.includes('pkcs7') ||
type.includes('x-pkcs7') ||
type === 'application/pkcs7-mime' ||
type === 'application/pkcs7-signature'
);
}
/**
* Fold a single iCalendar content line per RFC 5545 §3.1.
* Lines longer than 75 octets MUST be split with CRLF + a single linear white space character.
@@ -347,6 +374,33 @@ function sanitizeIdentityDisplayName(name: string | undefined | null): string {
return name.replace(/\s*<[^>]*>\s*$/, '').trim();
}
function normalizeEnvelopeRecipients(recipients?: Array<string | EmailAddress>): Array<{ email: string }> {
return (recipients || [])
.map((recipient) => typeof recipient === 'string' ? recipient : recipient.email)
.map((email) => email.trim())
.filter(Boolean)
.map((email) => ({ email }));
}
function createDelayedSubmissionEnvelope(fromEmail: string, holdForSeconds?: number, recipients?: Array<string | EmailAddress>): Record<string, unknown> | undefined {
if (!holdForSeconds) return undefined;
const rcptTo = normalizeEnvelopeRecipients(recipients);
return {
mailFrom: {
email: fromEmail,
parameters: {
HOLDFOR: String(holdForSeconds),
},
},
rcptTo,
};
}
type SubmissionCapability = {
maxDelayedSend?: number;
submissionExtensions?: unknown;
};
export class JMAPClient implements IJMAPClient {
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
@@ -703,7 +757,7 @@ export class JMAPClient implements IJMAPClient {
}
const requestBody = {
using: using || ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
using: using || (hasSubmissionMethod(methodCalls) ? [...SUBMISSION_USING] : ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"]),
methodCalls,
};
@@ -2094,8 +2148,10 @@ export class JMAPClient implements IJMAPClient {
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[],
references?: string[],
delayedUntil?: string,
envelopeMailFrom?: string
): Promise<void> {
): Promise<SendEmailResult> {
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
const emailId = `send-${Date.now()}`;
const mailboxes = await this.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
@@ -2197,12 +2253,19 @@ export class JMAPClient implements IJMAPClient {
// e.g. sending from a domain-catch-all alias without a dedicated Identity),
// set the EmailSubmission envelope explicitly. JMAP §7.3: when `envelope`
// is omitted the server derives mailFrom from the Identity.
const submissionCreate = (submissionId: string): Record<string, unknown> => {
const buildSubmissionCreate = (submissionId: string): Record<string, unknown> => {
const create: Record<string, unknown> = { emailId: `#${emailId}`, identityId: finalIdentityId };
if (envelopeMailFrom) {
if (holdForSeconds || envelopeMailFrom) {
const envelopeRecipients = [...to, ...(cc || []), ...(bcc || [])]
.map((email) => email.trim())
.filter(Boolean)
.map((email) => ({ email }));
create.envelope = {
mailFrom: { email: envelopeMailFrom },
rcptTo: [...to, ...(cc || []), ...(bcc || [])].map((email) => ({ email })),
mailFrom: {
email: envelopeMailFrom || fromEmail || this.username,
...(holdForSeconds ? { parameters: { HOLDFOR: String(holdForSeconds) } } : {}),
},
rcptTo: envelopeRecipients,
};
}
return { [submissionId]: create };
@@ -2219,8 +2282,8 @@ export class JMAPClient implements IJMAPClient {
create: { [emailId]: emailCreate },
}, "1"]);
methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId,
create: submissionCreate("1"),
accountId: this.getSubmissionAccountId(),
create: buildSubmissionCreate("1"),
onSuccessUpdateEmail,
}, "2"]);
} else {
@@ -2229,14 +2292,18 @@ export class JMAPClient implements IJMAPClient {
create: { [emailId]: emailCreate },
}, "0"]);
methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId,
create: submissionCreate("1"),
accountId: this.getSubmissionAccountId(),
create: buildSubmissionCreate("1"),
onSuccessUpdateEmail,
}, "1"]);
}
const response = await this.request(methodCalls);
let createdEmailId: string | undefined;
let emailSubmissionId: string | undefined;
let serverSendAt: string | undefined;
if (response.methodResponses) {
for (const [methodName, result] of response.methodResponses) {
if (methodName.endsWith('/error')) {
@@ -2268,8 +2335,24 @@ export class JMAPClient implements IJMAPClient {
`${firstError?.description || firstError?.type || 'Failed to send email'}${typeHint}${propsHint}`,
);
}
if (methodName === 'Email/set' && result.created?.[emailId]?.id) {
createdEmailId = result.created[emailId].id;
}
if (methodName === 'EmailSubmission/set' && result.created?.['1']?.id) {
emailSubmissionId = result.created['1'].id;
serverSendAt = result.created['1'].sendAt;
}
}
}
if (delayedUntil && emailSubmissionId && !serverSendAt) {
serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId);
}
return delayedUntil
? { scheduled: true, emailId: createdEmailId, emailSubmissionId, sendAt: serverSendAt }
: { scheduled: false, emailId: createdEmailId, emailSubmissionId };
}
/**
@@ -2431,7 +2514,7 @@ export class JMAPClient implements IJMAPClient {
create: { [emailId]: emailCreate },
}, "0"],
["EmailSubmission/set", {
accountId: this.accountId,
accountId: this.getSubmissionAccountId(),
create: { "sub-1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
onSuccessUpdateEmail: {
"#sub-1": {
@@ -2609,7 +2692,7 @@ export class JMAPClient implements IJMAPClient {
create: { [emailId]: emailCreate },
}, "0"],
["EmailSubmission/set", {
accountId: this.accountId,
accountId: this.getSubmissionAccountId(),
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
onSuccessUpdateEmail: {
"#sub-1": {
@@ -2761,7 +2844,7 @@ export class JMAPClient implements IJMAPClient {
create: { [emailId]: emailCreate },
}, "0"],
["EmailSubmission/set", {
accountId: this.accountId,
accountId: this.getSubmissionAccountId(),
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
onSuccessUpdateEmail: {
"#sub-1": {
@@ -2975,6 +3058,85 @@ export class JMAPClient implements IJMAPClient {
return coreCapability?.maxObjectsInGet || 500;
}
getMaxDelayedSend(accountId?: string): number {
const maxDelayedSend = this.getSubmissionCapability(accountId)?.maxDelayedSend;
return typeof maxDelayedSend === 'number' ? maxDelayedSend : 0;
}
hasDelayedSend(accountId?: string): boolean {
const submissionCapability = this.getSubmissionCapability(accountId);
const submissionExtensions = submissionCapability?.submissionExtensions;
const hasFutureRelease = this.hasSubmissionExtension(submissionExtensions, 'FUTURERELEASE');
return !!submissionCapability
&& hasFutureRelease
&& this.getMaxDelayedSend(accountId) > 0;
}
private validateDelayedUntil(delayedUntil: string, accountId?: string): number {
const time = new Date(delayedUntil).getTime();
if (!Number.isFinite(time)) {
throw new Error('Scheduled send time is invalid');
}
const now = Date.now();
if (time <= now) {
throw new Error('Scheduled send time must be in the future');
}
const maxDelayedSend = this.getMaxDelayedSend(accountId);
if (!this.hasDelayedSend(accountId) || maxDelayedSend <= 0) {
throw new Error('Scheduled send is not supported for this account');
}
if (time > now + maxDelayedSend * 1000) {
throw new Error('Scheduled send time is later than the server allows');
}
return Math.ceil((time - now) / 1000);
}
private async getEmailSubmissionSendAt(submissionId: string): Promise<string | undefined> {
const response = await this.request([
['EmailSubmission/get', {
accountId: this.getSubmissionAccountId(),
ids: [submissionId],
properties: ['sendAt', 'undoStatus'],
}, '0'],
]);
const submission = response.methodResponses?.[0]?.[1]?.list?.[0] as { sendAt?: string } | undefined;
return submission?.sendAt;
}
private async getEmailSubmissionEnvelope(submissionId: string): Promise<{ rcptTo?: Array<{ email: string }> } | undefined> {
const response = await this.request([
['EmailSubmission/get', {
accountId: this.getSubmissionAccountId(),
ids: [submissionId],
properties: ['envelope'],
}, '0'],
]);
const submission = response.methodResponses?.[0]?.[1]?.list?.[0] as { envelope?: { rcptTo?: Array<{ email: string }> } } | undefined;
return submission?.envelope;
}
private getSubmissionAccountId(accountId?: string): string {
return accountId || this.session?.primaryAccounts?.['urn:ietf:params:jmap:submission'] || this.accountId;
}
private getSubmissionCapability(accountId?: string): SubmissionCapability | undefined {
const submissionAccountId = this.getSubmissionAccountId(accountId);
return this.session?.accounts?.[submissionAccountId]?.accountCapabilities?.['urn:ietf:params:jmap:submission'] as SubmissionCapability | undefined;
}
private hasSubmissionExtension(submissionExtensions: unknown, extension: string): boolean {
const target = extension.toUpperCase();
if (Array.isArray(submissionExtensions)) {
return submissionExtensions.some(item => typeof item === 'string' && item.toUpperCase() === target);
}
if (submissionExtensions && typeof submissionExtensions === 'object') {
return Object.entries(submissionExtensions as Record<string, unknown>)
.some(([key, value]) => key.toUpperCase() === target && value !== false && value != null);
}
return false;
}
getEventSourceUrl(): string | null {
if (!this.session) return null;
@@ -5430,7 +5592,7 @@ export class JMAPClient implements IJMAPClient {
async submitEmail(emailId: string, identityId: string): Promise<void> {
const response = await this.request([
['EmailSubmission/set', {
accountId: this.accountId,
accountId: this.getSubmissionAccountId(),
create: { 'smime-submit': { emailId, identityId } },
}, '0'],
]);
@@ -5451,7 +5613,10 @@ export class JMAPClient implements IJMAPClient {
identityId: string,
sentMailboxId: string,
draftMailboxId?: string,
): Promise<void> {
delayedUntil?: string,
envelopeRecipients?: string[],
): Promise<SendEmailResult> {
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
// Upload the raw message
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
const { blobId } = await this.uploadBlob(file);
@@ -5459,6 +5624,10 @@ export class JMAPClient implements IJMAPClient {
// Import into Drafts first, then move to Sent after submission succeeds.
// This avoids encrypt-on-append affecting the SMTP send. See #188.
const importMailboxId = draftMailboxId || sentMailboxId;
const identities = await this.getIdentities();
const identity = identities.find(item => item.id === identityId);
const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients);
const methodCalls: [string, Record<string, unknown>, string][] = [
['Email/import', {
accountId: this.accountId,
@@ -5471,11 +5640,12 @@ export class JMAPClient implements IJMAPClient {
},
}, '0'],
['EmailSubmission/set', {
accountId: this.accountId,
accountId: this.getSubmissionAccountId(),
create: {
'raw-submit': {
emailId: '#raw-import',
identityId,
...(envelope ? { envelope } : {}),
},
},
...(draftMailboxId ? {
@@ -5491,6 +5661,9 @@ export class JMAPClient implements IJMAPClient {
];
const response = await this.request(methodCalls);
let emailId: string | undefined;
let emailSubmissionId: string | undefined;
let serverSendAt: string | undefined;
// Check for errors
for (const [methodName, result] of response.methodResponses ?? []) {
@@ -5502,6 +5675,201 @@ export class JMAPClient implements IJMAPClient {
const firstErr = Object.values(r.notCreated)[0];
throw new Error(firstErr?.description || firstErr?.type || 'Failed to send raw email');
}
if (methodName === 'Email/import') {
emailId = (result as { created?: Record<string, { id?: string }> }).created?.['raw-import']?.id;
}
if (methodName === 'EmailSubmission/set') {
const created = (result as { created?: Record<string, { id?: string; sendAt?: string }> }).created?.['raw-submit'];
emailSubmissionId = created?.id;
serverSendAt = created?.sendAt;
}
}
if (delayedUntil && emailSubmissionId && !serverSendAt) {
serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId);
}
return delayedUntil
? { scheduled: true, emailId, emailSubmissionId, sendAt: serverSendAt, isSmime: true }
: { scheduled: false, emailId, emailSubmissionId, isSmime: true };
}
async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }> {
if (!this.hasDelayedSend()) {
return { emails: [], hasMore: false, total: 0, nextPosition: position };
}
const now = Date.now();
const pageSize = Math.max(limit, 50);
const submissions: EmailSubmission[] = [];
let rawPosition = 0;
let rawTotal = 0;
do {
const queryResponse = await this.request([
['EmailSubmission/query', {
accountId: this.getSubmissionAccountId(),
limit: pageSize,
position: rawPosition,
}, '0'],
]);
const query = queryResponse.methodResponses?.[0]?.[1] as { ids?: string[]; total?: number; position?: number } | undefined;
const ids = query?.ids ?? [];
rawTotal = query?.total ?? rawPosition + ids.length;
if (ids.length === 0) break;
const submissionResponse = await this.request([
['EmailSubmission/get', {
accountId: this.getSubmissionAccountId(),
ids,
properties: ['id', 'emailId', 'identityId', 'threadId', 'sendAt', 'undoStatus', 'deliveryStatus'],
}, '0'],
]);
submissions.push(...((submissionResponse.methodResponses?.[0]?.[1]?.list ?? []) as EmailSubmission[])
.filter(submission => {
if (submission.undoStatus !== 'pending' || !submission.sendAt) return false;
const sendAtTime = new Date(submission.sendAt).getTime();
return Number.isFinite(sendAtTime) && sendAtTime > now;
}));
rawPosition += ids.length;
} while (rawPosition < rawTotal);
submissions.sort((a, b) => new Date(a.sendAt || '').getTime() - new Date(b.sendAt || '').getTime());
const total = submissions.length;
const pageSubmissions = submissions.slice(position, position + limit);
const nextPosition = position + pageSubmissions.length;
if (pageSubmissions.length === 0) {
return { emails: [], hasMore: false, total, nextPosition };
}
const emailResponse = await this.request([
['Email/get', {
accountId: this.accountId,
ids: pageSubmissions.map(submission => submission.emailId),
properties: [
'id', 'threadId', 'mailboxIds', 'keywords', 'size', 'receivedAt', 'from', 'to', 'cc', 'bcc', 'replyTo',
'subject', 'preview', 'textBody', 'htmlBody', 'bodyValues', 'attachments', 'hasAttachment', 'sentAt',
'messageId', 'inReplyTo', 'references', 'headers', 'blobId', 'bodyStructure',
],
fetchTextBodyValues: true,
fetchHTMLBodyValues: true,
fetchAllBodyValues: true,
maxBodyValueBytes: 256000,
}, '0'],
]);
const emailById = new Map(((emailResponse.methodResponses?.[0]?.[1]?.list ?? []) as Email[]).map(email => [email.id, email]));
const emails = pageSubmissions
.map((submission): ScheduledEmail | null => {
const email = emailById.get(submission.emailId);
if (!email || !submission.sendAt) return null;
return {
...email,
threadId: submission.threadId || email.threadId,
scheduledSendAt: submission.sendAt,
emailSubmissionId: submission.id,
scheduledIdentityId: submission.identityId,
scheduledUndoStatus: submission.undoStatus,
scheduledDeliveryStatus: submission.deliveryStatus,
isScheduled: true,
isSmimeScheduled: isSmimeEmail(email),
};
})
.filter((email): email is ScheduledEmail => email !== null)
.sort((a, b) => new Date(a.scheduledSendAt).getTime() - new Date(b.scheduledSendAt).getTime());
return { emails, hasMore: nextPosition < total, total, nextPosition };
}
async cancelEmailSubmission(submissionId: string): Promise<void> {
const response = await this.request([
['EmailSubmission/set', {
accountId: this.getSubmissionAccountId(),
update: { [submissionId]: { undoStatus: 'canceled' } },
}, '0'],
]);
const result = response.methodResponses?.[0]?.[1];
const error = result?.notUpdated?.[submissionId];
if (error) {
throw new Error(error.description || error.type || 'Failed to cancel scheduled send');
}
}
async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult> {
const holdForSeconds = this.validateDelayedUntil(delayedUntil);
const mailboxes = await this.getMailboxes();
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
const identities = await this.getIdentities();
const identity = identities.find(item => item.id === identityId);
const existingEnvelope = await this.getEmailSubmissionEnvelope(submissionId);
const email = existingEnvelope?.rcptTo?.length ? undefined : await this.getEmail(emailId);
const envelopeRecipients = existingEnvelope?.rcptTo?.length
? existingEnvelope.rcptTo
: [...(email?.to || []), ...(email?.cc || []), ...(email?.bcc || [])];
const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients);
const response = await this.request([
['EmailSubmission/set', {
accountId: this.getSubmissionAccountId(),
create: { replacement: { emailId, identityId, ...(envelope ? { envelope } : {}) } },
...(draftsMailbox && sentMailbox ? {
onSuccessUpdateEmail: {
'#replacement': {
[`mailboxIds/${draftsMailbox.id}`]: null,
[`mailboxIds/${sentMailbox.id}`]: true,
'keywords/$draft': null,
},
},
} : {}),
}, '0'],
]);
const result = response.methodResponses?.[0]?.[1];
const createError = result?.notCreated?.replacement;
if (createError) {
throw new Error(createError.description || createError.type || 'Failed to reschedule email');
}
const replacementId = result?.created?.replacement?.id;
const serverSendAt = result?.created?.replacement?.sendAt;
if (!replacementId) {
throw new Error('Server did not return a replacement scheduled send ID');
}
const finalSendAt = serverSendAt || await this.getEmailSubmissionSendAt(replacementId);
try {
await this.cancelEmailSubmission(submissionId);
} catch (error) {
try {
await this.cancelEmailSubmission(replacementId);
} catch (cleanupError) {
console.error('Failed to clean up replacement scheduled send after reschedule failure:', cleanupError);
}
const message = error instanceof Error ? error.message : 'Failed to cancel original scheduled send';
throw new Error(`Reschedule created a replacement but could not cancel the original: ${message}`);
}
return { scheduled: true, emailId, emailSubmissionId: replacementId, sendAt: finalSendAt };
}
async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void> {
const update: Record<string, unknown> = {
[`mailboxIds/${draftMailboxId}`]: true,
'keywords/$draft': true,
};
if (sentMailboxId) {
update[`mailboxIds/${sentMailboxId}`] = null;
}
const response = await this.request([
['Email/set', {
accountId: this.accountId,
update: { [emailId]: update },
}, '0'],
]);
const result = response.methodResponses?.[0]?.[1];
const error = result?.notUpdated?.[emailId];
if (error) {
throw new Error(error.description || error.type || 'Failed to restore email to drafts');
}
}
@@ -5580,4 +5948,4 @@ export class JMAPClient implements IJMAPClient {
['urn:ietf:params:jmap:core'],
);
}
}
}
+27 -1
View File
@@ -42,6 +42,32 @@ export interface Email {
// Unified mailbox support - set when displaying emails from multiple accounts
accountId?: string;
accountLabel?: string;
// Client-only scheduled-send metadata, populated from EmailSubmission/query.
scheduledSendAt?: string;
emailSubmissionId?: string;
scheduledIdentityId?: string;
scheduledUndoStatus?: 'pending' | 'final' | 'canceled';
scheduledDeliveryStatus?: Record<string, DeliveryStatus>;
isScheduled?: boolean;
isSmimeScheduled?: boolean;
}
export interface SendEmailResult {
scheduled: boolean;
emailId?: string;
emailSubmissionId?: string;
sendAt?: string;
isSmime?: boolean;
}
export interface ScheduledEmail extends Email {
scheduledSendAt: string;
emailSubmissionId: string;
scheduledIdentityId: string;
scheduledUndoStatus: 'pending' | 'final' | 'canceled';
scheduledDeliveryStatus?: Record<string, DeliveryStatus>;
isScheduled: true;
isSmimeScheduled: boolean;
}
export interface AuthenticationResults {
@@ -785,4 +811,4 @@ export const UNIFIED_ROLE_BY_ID: Record<string, UnifiedMailboxRole> = Object.fro
export function isUnifiedMailboxId(id: string): boolean {
return id in UNIFIED_ROLE_BY_ID;
}
}
+53 -5
View File
@@ -132,7 +132,8 @@
"shared": "Sdílené",
"mail": "Pošta",
"nav_label": "Navigace",
"add_app": "Aplikace"
"add_app": "Aplikace",
"scheduled": "Naplánováno"
},
"protocol_handlers": {
"title": "Výchozí aplikace",
@@ -239,7 +240,16 @@
"confirm_button": "Vyprázdnit složku",
"junk_hint": "Můžete vyprázdnit složku Spam a trvale odstranit všechny zprávy.",
"trash_hint": "Můžete vyprázdnit Koš a trvale odstranit všechny zprávy."
}
},
"no_scheduled_emails": "Žádné naplánované e-maily",
"no_scheduled_emails_description": "Zprávy naplánované na později se zobrazí zde.",
"scheduled_count": "{count, plural, one {1 naplánovaný e-mail} few {# naplánované e-maily} other {# naplánovaných e-mailů}}",
"scheduled_actions_hint": "Naplánované zprávy můžete zrušit, přeplánovat nebo upravit pomocí akcí plánování.",
"cancel_scheduled_send": "Zrušit odeslání",
"reschedule_send": "Přeplánovat",
"cancel_and_edit": "Zrušit a upravit",
"cancel_and_compose_again": "Zrušit a napsat znovu",
"reschedule_prompt": "Zadejte nové datum/čas, např. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Není vybrána žádná zpráva",
@@ -514,7 +524,17 @@
"ai_verdict": "Verdikt AI",
"account": "Účet",
"no_subject": "(bez předmětu)"
}
},
"scheduled_banner": "Naplánováno k odeslání {date}",
"scheduled_send_created": "E-mail byl naplánován k odeslání",
"cancel_scheduled_send": "Zrušit odeslání",
"reschedule_send": "Přeplánovat",
"cancel_and_edit": "Zrušit a upravit",
"cancel_and_compose_again": "Zrušit a napsat znovu",
"reschedule_prompt": "Zadejte nové datum/čas, např. 2026-05-04T15:30",
"scheduled_actions_only": "Zobrazení Naplánováno podporuje pouze akce plánování",
"undo_send_scheduled": "Zpráva je naplánována k odeslání",
"undo_send": "Vrátit odeslání"
},
"email_composer": {
"new_message": "Nová zpráva",
@@ -607,7 +627,19 @@
},
"add_link": "Přidat odkaz",
"link_url_prompt": "Zadejte URL",
"sending": "Odesílání..."
"sending": "Odesílání...",
"schedule_send": "Naplánovat odeslání",
"schedule_send_description": "Vyberte, kdy má server tuto zprávu odeslat.",
"schedule_send_required": "Vyberte datum a čas.",
"schedule_send_invalid": "Zadejte platné datum a čas.",
"schedule_send_future": "Vyberte budoucí datum a čas.",
"schedule_send_too_late": "Tento čas je později, než server dovoluje.",
"schedule_send_unsupported": "Naplánované odeslání není pro tento účet podporováno.",
"schedule_send_cleanup_warning": "Naplánované odeslání bylo vytvořeno, ale vyčištění konceptu selhalo.",
"send_delay_unsupported": "Prodleva odeslání není pro tento účet podporována.",
"send_delay_unsupported_confirm": "Tento účet nepodporuje prodlevu odeslání. Odeslat ihned?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Potvrdit",
@@ -923,6 +955,8 @@
"reenable": "Znovu zaregistrovat",
"relay_desc": "Výchozí je hostovaný relay Bulwark. Změňte pouze pokud používáte vlastní hosting.",
"relay_label": "Push relay",
"relay_locked": "Nastaveno administrátorem",
"relay_locked_desc": "URL push relay byla nastavena administrátorem a nelze ji změnit.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Aktivní na tomto zařízení",
"status_busy": "Pracuji…",
@@ -978,6 +1012,7 @@
"label": "Akce odstranění",
"description": "Co se má stát po odstranění e-mailu",
"trash": "Přesunout do Koše",
"trash_and_read": "Přesunout do Koše a označit jako přečtené",
"permanent": "Trvale odstranit",
"warning": "E-maily budou trvale odstraněny a nebude možné je obnovit. Tato akce je nevratná."
},
@@ -1134,6 +1169,13 @@
"attachment_image_previews": {
"label": "Zobrazit náhledy obrázků v přílohách",
"description": "Zobrazovat obrázkové přílohy jako miniatury místo obecných ikon souborů"
},
"send_delay": {
"label": "Vrátit odeslání / prodleva odeslání",
"description": "Pozdržet běžné odeslání krátkým oknem na serveru.",
"off": "Vypnuto",
"seconds": "{seconds} sekund",
"unsupported": "Aktuální účet neoznamuje podporu odloženého odeslání. Nastavení zůstane uloženo pro jiné účty."
}
},
"composer": {
@@ -1803,7 +1845,11 @@
"color_tag": "Štítek",
"remove_color": "Odebrat štítek",
"items_selected": "{count} vybraných zpráv",
"edit_draft": "Upravit koncept"
"edit_draft": "Upravit koncept",
"cancel_scheduled_send": "Zrušit odeslání",
"reschedule_send": "Přeplánovat",
"cancel_and_edit": "Zrušit a upravit",
"cancel_and_compose_again": "Zrušit a napsat znovu"
},
"mailbox_context_menu": {
"mark_folder_read": "Označit složku jako přečtenou",
@@ -1881,6 +1927,8 @@
"expand_collapse": "Rozbalit/sbalit vlákno"
},
"composer": {
"send": "Odeslat e-mail",
"schedule_send": "Naplánovat odeslání",
"template_picker": "Otevřít výběr šablon"
}
},
+51 -5
View File
@@ -132,7 +132,8 @@
"shared": "Delt",
"mail": "Mail",
"nav_label": "Navigation",
"add_app": "Apps"
"add_app": "Apps",
"scheduled": "Planlagt"
},
"protocol_handlers": {
"title": "Standardapps",
@@ -239,7 +240,16 @@
"confirm_button": "Tøm mappe",
"junk_hint": "Du kan tømme spam-mappen for permanent at fjerne alle beskeder.",
"trash_hint": "Du kan tømme papirkurven for permanent at fjerne alle beskeder."
}
},
"no_scheduled_emails": "Ingen planlagte e-mails",
"no_scheduled_emails_description": "Beskeder planlagt til senere vises her.",
"scheduled_count": "{count, plural, one {1 planlagt e-mail} other {# planlagte e-mails}}",
"scheduled_actions_hint": "Planlagte beskeder kan annulleres, planlægges om eller redigeres fra deres planlagte handlinger.",
"cancel_scheduled_send": "Annuller afsendelse",
"reschedule_send": "Planlæg om",
"cancel_and_edit": "Annuller og redigér",
"cancel_and_compose_again": "Annuller og skriv igen",
"reschedule_prompt": "Indtast en ny dato/tid, f.eks. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Ingen e-mail valgt",
@@ -514,7 +524,17 @@
"collapse": "Skjul detaljer"
},
"send": "Send",
"more": "mere"
"more": "mere",
"scheduled_banner": "Planlagt til afsendelse {date}",
"scheduled_send_created": "E-mail planlagt til afsendelse",
"cancel_scheduled_send": "Annuller afsendelse",
"reschedule_send": "Planlæg om",
"cancel_and_edit": "Annuller og redigér",
"cancel_and_compose_again": "Annuller og skriv igen",
"reschedule_prompt": "Indtast en ny dato/tid, f.eks. 2026-05-04T15:30",
"scheduled_actions_only": "Planlagt-visningen understøtter kun planlagte handlinger",
"undo_send_scheduled": "Besked planlagt til afsendelse",
"undo_send": "Fortryd afsendelse"
},
"email_composer": {
"new_message": "Ny besked",
@@ -607,7 +627,17 @@
"send_anyway": "Send alligevel",
"back": "Tilbage til redigering"
},
"link_url_prompt": "Indtast URL'en"
"link_url_prompt": "Indtast URL'en",
"schedule_send": "Planlæg afsendelse",
"schedule_send_description": "Vælg hvornår serveren skal frigive denne besked.",
"schedule_send_required": "Vælg en dato og et tidspunkt.",
"schedule_send_invalid": "Indtast en gyldig dato og tid.",
"schedule_send_future": "Vælg en fremtidig dato og tid.",
"schedule_send_too_late": "Dette tidspunkt er senere end serveren tillader.",
"schedule_send_unsupported": "Planlagt afsendelse understøttes ikke for denne konto.",
"schedule_send_cleanup_warning": "Planlagt afsendelse blev oprettet, men oprydning af kladde mislykkedes.",
"send_delay_unsupported": "Sendeforsinkelse understøttes ikke for denne konto.",
"send_delay_unsupported_confirm": "Denne konto understøtter ikke sendeforsinkelse. Send straks i stedet?"
},
"confirm_dialog": {
"confirm": "Bekræft",
@@ -897,6 +927,8 @@
"description": "Modtag systemnotifikationer for ny mail, når dette site er lukket. Leveres via Bulwark push-relæet; relæet ser aldrig mail-indhold.",
"relay_label": "Push-relæ",
"relay_desc": "Som standard det hosted Bulwark-relæ. Ændr kun, hvis du selv-host'er.",
"relay_locked": "Indstillet af administrator",
"relay_locked_desc": "Push-relæ-URL'en er indstillet af din administrator og kan ikke ændres.",
"relay_placeholder": "https://notifikationer.relay.eksempel.dk",
"status_active": "Aktiv på denne enhed",
"status_inactive": "Ikke aktiveret på denne enhed",
@@ -981,6 +1013,7 @@
"label": "Slet-handling",
"description": "Hvad der sker, når du sletter en e-mail",
"trash": "Flyt til papirkurv",
"trash_and_read": "Flyt til papirkurv og marker som læst",
"permanent": "Slet permanent",
"warning": "E-mails vil blive slettet permanent og kan ikke gendannes. Denne handling er irreversibel."
},
@@ -1137,6 +1170,13 @@
"attachment_image_previews": {
"label": "Vis billedforhåndsvisninger i vedhæftninger",
"description": "Vis billedvedhæftninger som miniaturekort i stedet for generiske filikoner"
},
"send_delay": {
"label": "Fortryd afsendelse / sendeforsinkelse",
"description": "Forsink normale afsendelser med et kort serverstyret tidsrum.",
"off": "Fra",
"seconds": "{seconds} sekunder",
"unsupported": "Den aktuelle konto annoncerer ikke understøttelse af forsinket afsendelse. Indstillingen gemmes stadig for andre konti."
}
},
"composer": {
@@ -1803,7 +1843,11 @@
"color_tag": "Tag",
"remove_color": "Fjern tag",
"items_selected": "{count} e-mails valgt",
"edit_draft": "Redigér kladde"
"edit_draft": "Redigér kladde",
"cancel_scheduled_send": "Annuller afsendelse",
"reschedule_send": "Planlæg om",
"cancel_and_edit": "Annuller og redigér",
"cancel_and_compose_again": "Annuller og skriv igen"
},
"mailbox_context_menu": {
"mark_folder_read": "Markér mappe som læst",
@@ -1881,6 +1925,8 @@
"expand_collapse": "Udvid/skjul tråd"
},
"composer": {
"send": "Send e-mail",
"schedule_send": "Planlæg afsendelse",
"template_picker": "Åbn skabelonvælger"
}
},
+53 -5
View File
@@ -132,7 +132,8 @@
"mail": "E-Mail",
"nav_label": "Navigation",
"add_app": "Apps",
"shared": "Geteilt"
"shared": "Geteilt",
"scheduled": "Geplant"
},
"protocol_handlers": {
"title": "Standard-Apps",
@@ -239,7 +240,16 @@
"confirm_button": "Ordner leeren",
"junk_hint": "Sie können den Spam-Ordner leeren, um alle Nachrichten dauerhaft zu entfernen.",
"trash_hint": "Sie können den Papierkorb leeren, um alle Nachrichten dauerhaft zu entfernen."
}
},
"no_scheduled_emails": "Keine geplanten E-Mails",
"no_scheduled_emails_description": "Nachrichten, die für später geplant sind, erscheinen hier.",
"scheduled_count": "{count, plural, one {1 geplante E-Mail} other {# geplante E-Mails}}",
"scheduled_actions_hint": "Geplante Nachrichten können über die Planungsaktionen abgebrochen, neu geplant oder bearbeitet werden.",
"cancel_scheduled_send": "Senden abbrechen",
"reschedule_send": "Neu planen",
"cancel_and_edit": "Abbrechen und bearbeiten",
"cancel_and_compose_again": "Abbrechen und neu verfassen",
"reschedule_prompt": "Neues Datum/Uhrzeit eingeben, z. B. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Keine E-Mail ausgewählt",
@@ -514,7 +524,17 @@
"ai_verdict": "KI-Urteil",
"account": "Konto",
"no_subject": "(kein Betreff)"
}
},
"scheduled_banner": "Geplant zum Senden um {date}",
"scheduled_send_created": "E-Mail wurde zum Senden geplant",
"cancel_scheduled_send": "Senden abbrechen",
"reschedule_send": "Neu planen",
"cancel_and_edit": "Abbrechen und bearbeiten",
"cancel_and_compose_again": "Abbrechen und neu verfassen",
"reschedule_prompt": "Neues Datum/Uhrzeit eingeben, z. B. 2026-05-04T15:30",
"scheduled_actions_only": "Die Geplant-Ansicht unterstützt nur Planungsaktionen",
"undo_send_scheduled": "Nachricht ist zum Senden geplant",
"undo_send": "Senden rückgängig"
},
"email_composer": {
"new_message": "Neue Nachricht",
@@ -607,7 +627,19 @@
},
"add_link": "Link hinzufügen",
"link_url_prompt": "URL eingeben",
"sending": "Wird gesendet..."
"sending": "Wird gesendet...",
"schedule_send": "Senden planen",
"schedule_send_description": "Wählen Sie, wann der Server diese Nachricht freigeben soll.",
"schedule_send_required": "Wählen Sie Datum und Uhrzeit.",
"schedule_send_invalid": "Geben Sie ein gültiges Datum und eine gültige Uhrzeit ein.",
"schedule_send_future": "Wählen Sie ein Datum und eine Uhrzeit in der Zukunft.",
"schedule_send_too_late": "Dieser Zeitpunkt liegt später, als der Server erlaubt.",
"schedule_send_unsupported": "Geplantes Senden wird für dieses Konto nicht unterstützt.",
"schedule_send_cleanup_warning": "Geplantes Senden wurde erstellt, aber das Bereinigen des Entwurfs ist fehlgeschlagen.",
"send_delay_unsupported": "Sendeverzögerung wird für dieses Konto nicht unterstützt.",
"send_delay_unsupported_confirm": "Dieses Konto unterstützt keine Sendeverzögerung. Stattdessen sofort senden?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Bestätigen",
@@ -923,6 +955,8 @@
"reenable": "Neu registrieren",
"relay_desc": "Standardmäßig wird das gehostete Bulwark-Relay verwendet. Nur ändern, wenn Sie selbst hosten.",
"relay_label": "Push-Relay",
"relay_locked": "Vom Administrator festgelegt",
"relay_locked_desc": "Die Push-Relay-URL wurde von Ihrem Administrator festgelegt und kann nicht geändert werden.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Auf diesem Gerät aktiv",
"status_busy": "Wird verarbeitet…",
@@ -978,6 +1012,7 @@
"label": "Löschaktion",
"description": "Was passiert, wenn Sie eine E-Mail löschen",
"trash": "In Papierkorb verschieben",
"trash_and_read": "In Papierkorb verschieben und als gelesen markieren",
"permanent": "Dauerhaft löschen",
"warning": "E-Mails werden dauerhaft gelöscht und können nicht wiederhergestellt werden. Diese Aktion ist unwiderruflich."
},
@@ -1134,6 +1169,13 @@
"attachment_image_previews": {
"label": "Bildvorschau in Anhängen anzeigen",
"description": "Bildanhänge als Miniaturansichten statt als generische Dateisymbole anzeigen"
},
"send_delay": {
"label": "Senden rückgängig / Sendeverzögerung",
"description": "Normales Senden serverseitig kurz verzögern.",
"off": "Aus",
"seconds": "{seconds} Sekunden",
"unsupported": "Das aktuelle Konto meldet keine Unterstützung für verzögertes Senden. Die Einstellung bleibt für andere Konten gespeichert."
}
},
"composer": {
@@ -1803,7 +1845,11 @@
"color_tag": "Label",
"remove_color": "Label entfernen",
"items_selected": "{count} E-Mails ausgewählt",
"edit_draft": "Entwurf bearbeiten"
"edit_draft": "Entwurf bearbeiten",
"cancel_scheduled_send": "Senden abbrechen",
"reschedule_send": "Neu planen",
"cancel_and_edit": "Abbrechen und bearbeiten",
"cancel_and_compose_again": "Abbrechen und neu verfassen"
},
"mailbox_context_menu": {
"mark_folder_read": "Ordner als gelesen markieren",
@@ -1881,6 +1927,8 @@
"expand_collapse": "Unterhaltung erweitern/einklappen"
},
"composer": {
"send": "E-Mail senden",
"schedule_send": "Senden planen",
"template_picker": "Vorlagenauswahl öffnen"
}
},
+51 -7
View File
@@ -132,7 +132,8 @@
"shared": "Shared",
"mail": "Mail",
"nav_label": "Navigation",
"add_app": "Apps"
"add_app": "Apps",
"scheduled": "Scheduled"
},
"protocol_handlers": {
"title": "Default apps",
@@ -239,7 +240,16 @@
"confirm_button": "Empty folder",
"junk_hint": "You can empty the Junk folder to permanently remove all messages.",
"trash_hint": "You can empty the Trash folder to permanently remove all messages."
}
},
"no_scheduled_emails": "No scheduled emails",
"no_scheduled_emails_description": "Messages scheduled for later will appear here.",
"scheduled_count": "{count, plural, one {1 scheduled email} other {# scheduled emails}}",
"scheduled_actions_hint": "Scheduled messages can be canceled, rescheduled, or edited from their scheduled actions.",
"cancel_scheduled_send": "Cancel send",
"reschedule_send": "Reschedule",
"cancel_and_edit": "Cancel and edit",
"cancel_and_compose_again": "Cancel and compose again",
"reschedule_prompt": "Enter a new date/time, e.g. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "No email selected",
@@ -514,7 +524,17 @@
"collapse": "Hide details"
},
"send": "Send",
"more": "more"
"more": "more",
"scheduled_banner": "Scheduled to send at {date}",
"scheduled_send_created": "Email scheduled for sending",
"cancel_scheduled_send": "Cancel send",
"reschedule_send": "Reschedule",
"cancel_and_edit": "Cancel and edit",
"cancel_and_compose_again": "Cancel and compose again",
"reschedule_prompt": "Enter a new date/time, e.g. 2026-05-04T15:30",
"scheduled_actions_only": "Scheduled view supports scheduled actions only",
"undo_send_scheduled": "Message scheduled for sending",
"undo_send": "Undo send"
},
"email_composer": {
"new_message": "New Message",
@@ -607,7 +627,17 @@
"message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
"send_anyway": "Send anyway",
"back": "Back to editing"
}
},
"schedule_send": "Schedule send",
"schedule_send_description": "Choose when the server should release this message.",
"schedule_send_required": "Choose a date and time.",
"schedule_send_invalid": "Enter a valid date and time.",
"schedule_send_future": "Choose a future date and time.",
"schedule_send_too_late": "This time is later than the server allows.",
"schedule_send_unsupported": "Scheduled send is not supported for this account.",
"schedule_send_cleanup_warning": "Scheduled send was created, but draft cleanup failed.",
"send_delay_unsupported": "Send delay is not supported for this account.",
"send_delay_unsupported_confirm": "This account does not support send delay. Send immediately instead?"
},
"confirm_dialog": {
"confirm": "Confirm",
@@ -897,6 +927,8 @@
"description": "Receive system notifications for new mail when this site is closed. Delivered via the Bulwark push relay; the relay never sees mail content.",
"relay_label": "Push relay",
"relay_desc": "Defaults to the hosted Bulwark relay. Change only if you self-host.",
"relay_locked": "Set by administrator",
"relay_locked_desc": "The push relay URL has been set by your administrator and cannot be changed.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Active on this device",
"status_inactive": "Not enabled on this device",
@@ -1138,6 +1170,13 @@
"attachment_image_previews": {
"label": "Show image previews in attachments",
"description": "Render image attachments as thumbnail cards instead of generic file icons"
},
"send_delay": {
"label": "Undo send / send delay",
"description": "Delay normal sends by a short server-side window.",
"off": "Off",
"seconds": "{seconds} seconds",
"unsupported": "The current account does not advertise delayed-send support. The setting is still saved for other accounts."
}
},
"composer": {
@@ -1804,7 +1843,11 @@
"color_tag": "Tag",
"remove_color": "Remove tag",
"items_selected": "{count} emails selected",
"edit_draft": "Edit Draft"
"edit_draft": "Edit Draft",
"cancel_scheduled_send": "Cancel send",
"reschedule_send": "Reschedule",
"cancel_and_edit": "Cancel and edit",
"cancel_and_compose_again": "Cancel and compose again"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
@@ -1882,8 +1925,9 @@
"expand_collapse": "Expand/collapse thread"
},
"composer": {
"template_picker": "Open template picker",
"send": "Send email"
"send": "Send email",
"schedule_send": "Schedule send",
"template_picker": "Open template picker"
}
},
"threads": {
+53 -5
View File
@@ -132,7 +132,8 @@
"mail": "Correo",
"nav_label": "Navegación",
"add_app": "Apps",
"shared": "Compartido"
"shared": "Compartido",
"scheduled": "Programados"
},
"protocol_handlers": {
"title": "Aplicaciones predeterminadas",
@@ -239,7 +240,16 @@
"confirm_button": "Vaciar carpeta",
"junk_hint": "Puede vaciar la carpeta de Spam para eliminar permanentemente todos los mensajes.",
"trash_hint": "Puede vaciar la Papelera para eliminar permanentemente todos los mensajes."
}
},
"no_scheduled_emails": "No hay correos programados",
"no_scheduled_emails_description": "Los mensajes programados para más tarde aparecerán aquí.",
"scheduled_count": "{count, plural, one {1 correo programado} other {# correos programados}}",
"scheduled_actions_hint": "Los mensajes programados se pueden cancelar, reprogramar o editar desde sus acciones.",
"cancel_scheduled_send": "Cancelar envío",
"reschedule_send": "Reprogramar",
"cancel_and_edit": "Cancelar y editar",
"cancel_and_compose_again": "Cancelar y redactar de nuevo",
"reschedule_prompt": "Introduce una nueva fecha/hora, p. ej. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Ningún correo seleccionado",
@@ -514,7 +524,17 @@
"ai_verdict": "Veredicto de IA",
"account": "Cuenta",
"no_subject": "(sin asunto)"
}
},
"scheduled_banner": "Programado para enviarse el {date}",
"scheduled_send_created": "Correo programado para envío",
"cancel_scheduled_send": "Cancelar envío",
"reschedule_send": "Reprogramar",
"cancel_and_edit": "Cancelar y editar",
"cancel_and_compose_again": "Cancelar y redactar de nuevo",
"reschedule_prompt": "Introduce una nueva fecha/hora, p. ej. 2026-05-04T15:30",
"scheduled_actions_only": "La vista Programados solo admite acciones de programación",
"undo_send_scheduled": "Mensaje programado para envío",
"undo_send": "Deshacer envío"
},
"email_composer": {
"new_message": "Nuevo Mensaje",
@@ -607,7 +627,19 @@
},
"add_link": "Añadir enlace",
"link_url_prompt": "Introduce la URL",
"sending": "Enviando..."
"sending": "Enviando...",
"schedule_send": "Programar envío",
"schedule_send_description": "Elige cuándo debe liberar el servidor este mensaje.",
"schedule_send_required": "Elige una fecha y hora.",
"schedule_send_invalid": "Introduce una fecha y hora válidas.",
"schedule_send_future": "Elige una fecha y hora futuras.",
"schedule_send_too_late": "Esta hora es posterior a lo que permite el servidor.",
"schedule_send_unsupported": "El envío programado no es compatible con esta cuenta.",
"schedule_send_cleanup_warning": "Se creó el envío programado, pero falló la limpieza del borrador.",
"send_delay_unsupported": "La demora de envío no es compatible con esta cuenta.",
"send_delay_unsupported_confirm": "Esta cuenta no admite demora de envío. ¿Enviar inmediatamente?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Confirmar",
@@ -923,6 +955,8 @@
"reenable": "Volver a registrar",
"relay_desc": "Usa el relay alojado de Bulwark de forma predeterminada. Cámbialo solo si te alojas tú mismo.",
"relay_label": "Relay push",
"relay_locked": "Establecido por el administrador",
"relay_locked_desc": "La URL del relay push ha sido establecida por tu administrador y no se puede cambiar.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Activo en este dispositivo",
"status_busy": "Trabajando…",
@@ -978,6 +1012,7 @@
"label": "Acción al Eliminar",
"description": "Qué sucede cuando elimina un correo",
"trash": "Mover a Papelera",
"trash_and_read": "Mover a Papelera y marcar como leído",
"permanent": "Eliminar Permanentemente",
"warning": "Los correos se eliminarán permanentemente y no se podrán recuperar. Esta acción es irreversible."
},
@@ -1134,6 +1169,13 @@
"attachment_image_previews": {
"label": "Mostrar vistas previas de imágenes en adjuntos",
"description": "Mostrar las imágenes adjuntas como tarjetas de miniatura en lugar de iconos de archivo genéricos"
},
"send_delay": {
"label": "Deshacer envío / demora de envío",
"description": "Retrasa envíos normales con una breve ventana del servidor.",
"off": "Desactivado",
"seconds": "{seconds} segundos",
"unsupported": "La cuenta actual no anuncia compatibilidad con envío demorado. La opción seguirá guardada para otras cuentas."
}
},
"composer": {
@@ -1803,7 +1845,11 @@
"color_tag": "Etiqueta",
"remove_color": "Eliminar etiqueta",
"items_selected": "{count} correos seleccionados",
"edit_draft": "Editar borrador"
"edit_draft": "Editar borrador",
"cancel_scheduled_send": "Cancelar envío",
"reschedule_send": "Reprogramar",
"cancel_and_edit": "Cancelar y editar",
"cancel_and_compose_again": "Cancelar y redactar de nuevo"
},
"mailbox_context_menu": {
"mark_folder_read": "Marcar carpeta como leída",
@@ -1881,6 +1927,8 @@
"expand_collapse": "Expandir/contraer conversación"
},
"composer": {
"send": "Enviar correo",
"schedule_send": "Programar envío",
"template_picker": "Abrir selector de plantillas"
}
},
+53 -5
View File
@@ -132,7 +132,8 @@
"mail": "Messagerie",
"nav_label": "Navigation",
"add_app": "Apps",
"shared": "Partagé"
"shared": "Partagé",
"scheduled": "Planifiés"
},
"protocol_handlers": {
"title": "Applications par défaut",
@@ -239,7 +240,16 @@
"confirm_button": "Vider le dossier",
"junk_hint": "Vous pouvez vider le dossier Indésirables pour supprimer définitivement tous les messages.",
"trash_hint": "Vous pouvez vider la corbeille pour supprimer définitivement tous les messages."
}
},
"no_scheduled_emails": "Aucun e-mail planifié",
"no_scheduled_emails_description": "Les messages planifiés pour plus tard apparaîtront ici.",
"scheduled_count": "{count, plural, one {1 e-mail planifié} other {# e-mails planifiés}}",
"scheduled_actions_hint": "Les messages planifiés peuvent être annulés, replanifiés ou modifiés depuis leurs actions.",
"cancel_scheduled_send": "Annuler lenvoi",
"reschedule_send": "Replanifier",
"cancel_and_edit": "Annuler et modifier",
"cancel_and_compose_again": "Annuler et rédiger à nouveau",
"reschedule_prompt": "Saisissez une nouvelle date/heure, p. ex. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Aucun email sélectionné",
@@ -514,7 +524,17 @@
"ai_verdict": "Verdict IA",
"account": "Compte",
"no_subject": "(sans objet)"
}
},
"scheduled_banner": "Envoi planifié à {date}",
"scheduled_send_created": "E-mail planifié pour envoi",
"cancel_scheduled_send": "Annuler lenvoi",
"reschedule_send": "Replanifier",
"cancel_and_edit": "Annuler et modifier",
"cancel_and_compose_again": "Annuler et rédiger à nouveau",
"reschedule_prompt": "Saisissez une nouvelle date/heure, p. ex. 2026-05-04T15:30",
"scheduled_actions_only": "La vue Planifiés prend uniquement en charge les actions de planification",
"undo_send_scheduled": "Message planifié pour envoi",
"undo_send": "Annuler lenvoi"
},
"email_composer": {
"new_message": "Nouveau message",
@@ -607,7 +627,19 @@
},
"add_link": "Ajouter un lien",
"link_url_prompt": "Saisissez l'URL",
"sending": "Envoi..."
"sending": "Envoi...",
"schedule_send": "Planifier lenvoi",
"schedule_send_description": "Choisissez quand le serveur doit libérer ce message.",
"schedule_send_required": "Choisissez une date et une heure.",
"schedule_send_invalid": "Saisissez une date et une heure valides.",
"schedule_send_future": "Choisissez une date et une heure futures.",
"schedule_send_too_late": "Cette heure est au-delà de ce que le serveur autorise.",
"schedule_send_unsupported": "Lenvoi planifié nest pas pris en charge pour ce compte.",
"schedule_send_cleanup_warning": "Lenvoi planifié a été créé, mais le nettoyage du brouillon a échoué.",
"send_delay_unsupported": "Le délai denvoi nest pas pris en charge pour ce compte.",
"send_delay_unsupported_confirm": "Ce compte ne prend pas en charge le délai denvoi. Envoyer immédiatement ?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Confirmer",
@@ -923,6 +955,8 @@
"reenable": "Réenregistrer",
"relay_desc": "Utilise par défaut le relais Bulwark hébergé. Ne le changez que si vous l'hébergez vous-même.",
"relay_label": "Relais push",
"relay_locked": "Défini par l'administrateur",
"relay_locked_desc": "L'URL du relais push a été définie par votre administrateur et ne peut pas être modifiée.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Actif sur cet appareil",
"status_busy": "Traitement en cours…",
@@ -978,6 +1012,7 @@
"label": "Action de suppression",
"description": "Que se passe-t-il quand vous supprimez un email",
"trash": "Déplacer vers la corbeille",
"trash_and_read": "Déplacer vers la corbeille et marquer comme lu",
"permanent": "Supprimer définitivement",
"warning": "Les emails seront supprimés définitivement et ne pourront pas être récupérés. Cette action est irréversible."
},
@@ -1134,6 +1169,13 @@
"attachment_image_previews": {
"label": "Afficher les aperçus d'images dans les pièces jointes",
"description": "Afficher les images jointes sous forme de vignettes plutôt qu'avec des icônes de fichier génériques"
},
"send_delay": {
"label": "Annuler lenvoi / délai denvoi",
"description": "Retarde les envois normaux avec une courte fenêtre côté serveur.",
"off": "Désactivé",
"seconds": "{seconds} secondes",
"unsupported": "Le compte actuel nannonce pas la prise en charge de lenvoi différé. Le réglage reste enregistré pour dautres comptes."
}
},
"composer": {
@@ -1803,7 +1845,11 @@
"color_tag": "Étiquette",
"remove_color": "Supprimer l'étiquette",
"items_selected": "{count} emails sélectionnés",
"edit_draft": "Modifier le brouillon"
"edit_draft": "Modifier le brouillon",
"cancel_scheduled_send": "Annuler lenvoi",
"reschedule_send": "Replanifier",
"cancel_and_edit": "Annuler et modifier",
"cancel_and_compose_again": "Annuler et rédiger à nouveau"
},
"mailbox_context_menu": {
"mark_folder_read": "Marquer le dossier comme lu",
@@ -1881,6 +1927,8 @@
"expand_collapse": "Développer/réduire la conversation"
},
"composer": {
"send": "Envoyer l'e-mail",
"schedule_send": "Planifier l'envoi",
"template_picker": "Ouvrir le sélecteur de modèles"
}
},
+53 -5
View File
@@ -132,7 +132,8 @@
"mail": "Posta",
"nav_label": "Navigazione",
"add_app": "App",
"shared": "Condiviso"
"shared": "Condiviso",
"scheduled": "Programmate"
},
"protocol_handlers": {
"title": "App predefinite",
@@ -239,7 +240,16 @@
"confirm_button": "Svuota cartella",
"junk_hint": "Puoi svuotare la cartella Spam per rimuovere definitivamente tutti i messaggi.",
"trash_hint": "Puoi svuotare il Cestino per rimuovere definitivamente tutti i messaggi."
}
},
"no_scheduled_emails": "Nessuna e-mail programmata",
"no_scheduled_emails_description": "I messaggi programmati per più tardi appariranno qui.",
"scheduled_count": "{count, plural, one {1 e-mail programmata} other {# e-mail programmate}}",
"scheduled_actions_hint": "I messaggi programmati possono essere annullati, riprogrammati o modificati dalle azioni di programmazione.",
"cancel_scheduled_send": "Annulla invio",
"reschedule_send": "Riprogramma",
"cancel_and_edit": "Annulla e modifica",
"cancel_and_compose_again": "Annulla e componi di nuovo",
"reschedule_prompt": "Inserisci una nuova data/ora, ad es. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Nessun messaggio selezionato",
@@ -514,7 +524,17 @@
"ai_verdict": "Verdetto IA",
"account": "Account",
"no_subject": "(nessun oggetto)"
}
},
"scheduled_banner": "Programmato per linvio il {date}",
"scheduled_send_created": "E-mail programmata per linvio",
"cancel_scheduled_send": "Annulla invio",
"reschedule_send": "Riprogramma",
"cancel_and_edit": "Annulla e modifica",
"cancel_and_compose_again": "Annulla e componi di nuovo",
"reschedule_prompt": "Inserisci una nuova data/ora, ad es. 2026-05-04T15:30",
"scheduled_actions_only": "La vista Programmate supporta solo azioni di programmazione",
"undo_send_scheduled": "Messaggio programmato per linvio",
"undo_send": "Annulla invio"
},
"email_composer": {
"new_message": "Nuovo messaggio",
@@ -607,7 +627,19 @@
},
"add_link": "Aggiungi link",
"link_url_prompt": "Inserisci l'URL",
"sending": "Invio in corso..."
"sending": "Invio in corso...",
"schedule_send": "Programma invio",
"schedule_send_description": "Scegli quando il server deve rilasciare questo messaggio.",
"schedule_send_required": "Scegli una data e unora.",
"schedule_send_invalid": "Inserisci una data e unora valide.",
"schedule_send_future": "Scegli una data e unora future.",
"schedule_send_too_late": "Questo orario supera il limite consentito dal server.",
"schedule_send_unsupported": "Linvio programmato non è supportato per questo account.",
"schedule_send_cleanup_warning": "Linvio programmato è stato creato, ma la pulizia della bozza non è riuscita.",
"send_delay_unsupported": "Il ritardo di invio non è supportato per questo account.",
"send_delay_unsupported_confirm": "Questo account non supporta il ritardo di invio. Inviare subito?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Conferma",
@@ -923,6 +955,8 @@
"reenable": "Registra di nuovo",
"relay_desc": "Per impostazione predefinita usa il relay Bulwark ospitato. Cambialo solo se ospiti in autonomia.",
"relay_label": "Relay push",
"relay_locked": "Impostato dall'amministratore",
"relay_locked_desc": "L'URL del relay push è stato impostato dall'amministratore e non può essere modificato.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Attivo su questo dispositivo",
"status_busy": "In elaborazione…",
@@ -978,6 +1012,7 @@
"label": "Azione di eliminazione",
"description": "Cosa accade quando elimini un messaggio",
"trash": "Sposta nel cestino",
"trash_and_read": "Sposta nel cestino e segna come letto",
"permanent": "Elimina definitivamente",
"warning": "I messaggi verranno eliminati definitivamente e non potranno essere recuperati. Questa azione è irreversibile."
},
@@ -1134,6 +1169,13 @@
"attachment_image_previews": {
"label": "Mostra anteprime delle immagini negli allegati",
"description": "Mostra gli allegati immagine come miniature anziché con icone di file generiche"
},
"send_delay": {
"label": "Annulla invio / ritardo di invio",
"description": "Ritarda gli invii normali con una breve finestra lato server.",
"off": "Disattivato",
"seconds": "{seconds} secondi",
"unsupported": "Laccount corrente non dichiara il supporto allinvio ritardato. Limpostazione resta salvata per altri account."
}
},
"composer": {
@@ -1803,7 +1845,11 @@
"color_tag": "Etichetta",
"remove_color": "Rimuovi etichetta",
"items_selected": "{count} messaggi selezionati",
"edit_draft": "Modifica bozza"
"edit_draft": "Modifica bozza",
"cancel_scheduled_send": "Annulla invio",
"reschedule_send": "Riprogramma",
"cancel_and_edit": "Annulla e modifica",
"cancel_and_compose_again": "Annulla e componi di nuovo"
},
"mailbox_context_menu": {
"mark_folder_read": "Segna cartella come letta",
@@ -1881,6 +1927,8 @@
"expand_collapse": "Espandi/comprimi conversazione"
},
"composer": {
"send": "Invia email",
"schedule_send": "Programma invio",
"template_picker": "Apri selettore modelli"
}
},
+53 -5
View File
@@ -132,7 +132,8 @@
"mail": "メール",
"nav_label": "ナビゲーション",
"add_app": "アプリ",
"shared": "共有"
"shared": "共有",
"scheduled": "予約済み"
},
"protocol_handlers": {
"title": "既定のアプリ",
@@ -239,7 +240,16 @@
"confirm_button": "フォルダを空にする",
"junk_hint": "迷惑メールフォルダを空にして、すべてのメールを完全に削除できます。",
"trash_hint": "ゴミ箱を空にして、すべてのメールを完全に削除できます。"
}
},
"no_scheduled_emails": "予約送信はありません",
"no_scheduled_emails_description": "後で送信するよう予約したメッセージがここに表示されます。",
"scheduled_count": "{count, plural, one {1 件の予約送信} other {# 件の予約送信}}",
"scheduled_actions_hint": "予約メッセージは予約アクションからキャンセル、再予約、編集できます。",
"cancel_scheduled_send": "送信をキャンセル",
"reschedule_send": "再予約",
"cancel_and_edit": "キャンセルして編集",
"cancel_and_compose_again": "キャンセルして新規作成",
"reschedule_prompt": "新しい日時を入力してください。例: 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "メールが選択されていません",
@@ -514,7 +524,17 @@
"ai_verdict": "AIの判定",
"account": "アカウント",
"no_subject": "(件名なし)"
}
},
"scheduled_banner": "{date} に送信予定",
"scheduled_send_created": "メールを送信予約しました",
"cancel_scheduled_send": "送信をキャンセル",
"reschedule_send": "再予約",
"cancel_and_edit": "キャンセルして編集",
"cancel_and_compose_again": "キャンセルして新規作成",
"reschedule_prompt": "新しい日時を入力してください。例: 2026-05-04T15:30",
"scheduled_actions_only": "予約済みビューでは予約アクションのみ利用できます",
"undo_send_scheduled": "メッセージは送信予約されています",
"undo_send": "送信を取り消す"
},
"email_composer": {
"new_message": "新規メッセージ",
@@ -607,7 +627,19 @@
},
"add_link": "リンクを追加",
"link_url_prompt": "URLを入力してください",
"sending": "送信中..."
"sending": "送信中...",
"schedule_send": "送信を予約",
"schedule_send_description": "サーバーがこのメッセージを送信する日時を選択します。",
"schedule_send_required": "日時を選択してください。",
"schedule_send_invalid": "有効な日時を入力してください。",
"schedule_send_future": "未来の日時を選択してください。",
"schedule_send_too_late": "この日時はサーバーが許可する範囲を超えています。",
"schedule_send_unsupported": "このアカウントでは予約送信はサポートされていません。",
"schedule_send_cleanup_warning": "予約送信は作成されましたが、下書きのクリーンアップに失敗しました。",
"send_delay_unsupported": "このアカウントでは送信遅延はサポートされていません。",
"send_delay_unsupported_confirm": "このアカウントは送信遅延に対応していません。すぐに送信しますか?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "確認",
@@ -923,6 +955,8 @@
"reenable": "再登録",
"relay_desc": "デフォルトはホストされた Bulwark リレーです。セルフホストする場合のみ変更してください。",
"relay_label": "プッシュリレー",
"relay_locked": "管理者が設定",
"relay_locked_desc": "プッシュリレーの URL は管理者によって設定されており、変更できません。",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "このデバイスで有効",
"status_busy": "処理中…",
@@ -978,6 +1012,7 @@
"label": "削除動作",
"description": "メール削除時の動作",
"trash": "ゴミ箱に移動",
"trash_and_read": "ゴミ箱に移動して既読にする",
"permanent": "完全に削除",
"warning": "メールは完全に削除され、復元できません。この操作は元に戻せません。"
},
@@ -1134,6 +1169,13 @@
"attachment_image_previews": {
"label": "添付ファイル内の画像プレビューを表示",
"description": "画像の添付ファイルを汎用ファイルアイコンではなくサムネイルカードとして表示します"
},
"send_delay": {
"label": "送信取り消し / 送信遅延",
"description": "通常の送信をサーバー側で短時間遅延します。",
"off": "オフ",
"seconds": "{seconds} 秒",
"unsupported": "現在のアカウントは遅延送信のサポートを通知していません。この設定は他のアカウント用に保存されます。"
}
},
"composer": {
@@ -1803,7 +1845,11 @@
"color_tag": "ラベル",
"remove_color": "ラベルを削除",
"items_selected": "{count}件のメールを選択",
"edit_draft": "下書きを編集"
"edit_draft": "下書きを編集",
"cancel_scheduled_send": "送信をキャンセル",
"reschedule_send": "再予約",
"cancel_and_edit": "キャンセルして編集",
"cancel_and_compose_again": "キャンセルして新規作成"
},
"mailbox_context_menu": {
"mark_folder_read": "フォルダーを既読にする",
@@ -1881,6 +1927,8 @@
"expand_collapse": "スレッドの展開/折りたたみ"
},
"composer": {
"send": "メールを送信",
"schedule_send": "送信を予約",
"template_picker": "テンプレートピッカーを開く"
}
},
+53 -5
View File
@@ -132,7 +132,8 @@
"mail": "메일",
"nav_label": "내비게이션",
"add_app": "앱",
"shared": "공유됨"
"shared": "공유됨",
"scheduled": "예약됨"
},
"protocol_handlers": {
"title": "기본 앱",
@@ -239,7 +240,16 @@
"confirm_button": "폴더 비우기",
"junk_hint": "스팸함을 비워 모든 메시지를 영구적으로 삭제할 수 있어요.",
"trash_hint": "휴지통을 비워 모든 메시지를 영구적으로 삭제할 수 있어요."
}
},
"no_scheduled_emails": "예약된 메일 없음",
"no_scheduled_emails_description": "나중에 보내도록 예약한 메시지가 여기에 표시됩니다.",
"scheduled_count": "{count, plural, one {예약된 메일 1개} other {예약된 메일 #개}}",
"scheduled_actions_hint": "예약된 메시지는 예약 작업에서 취소, 재예약 또는 편집할 수 있습니다.",
"cancel_scheduled_send": "보내기 취소",
"reschedule_send": "다시 예약",
"cancel_and_edit": "취소하고 편집",
"cancel_and_compose_again": "취소하고 다시 작성",
"reschedule_prompt": "새 날짜/시간을 입력하세요. 예: 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "메일이 선택되지 않았어요",
@@ -514,7 +524,17 @@
"ai_verdict": "AI 판정",
"account": "계정",
"no_subject": "(제목 없음)"
}
},
"scheduled_banner": "{date}에 보내도록 예약됨",
"scheduled_send_created": "메일 보내기가 예약되었습니다",
"cancel_scheduled_send": "보내기 취소",
"reschedule_send": "다시 예약",
"cancel_and_edit": "취소하고 편집",
"cancel_and_compose_again": "취소하고 다시 작성",
"reschedule_prompt": "새 날짜/시간을 입력하세요. 예: 2026-05-04T15:30",
"scheduled_actions_only": "예약됨 보기에서는 예약 작업만 사용할 수 있습니다",
"undo_send_scheduled": "메시지가 보내기로 예약되었습니다",
"undo_send": "보내기 실행 취소"
},
"email_composer": {
"new_message": "새 메시지",
@@ -607,7 +627,19 @@
},
"add_link": "링크 추가",
"link_url_prompt": "URL을 입력하세요",
"sending": "전송 중..."
"sending": "전송 중...",
"schedule_send": "보내기 예약",
"schedule_send_description": "서버가 이 메시지를 보낼 시간을 선택하세요.",
"schedule_send_required": "날짜와 시간을 선택하세요.",
"schedule_send_invalid": "유효한 날짜와 시간을 입력하세요.",
"schedule_send_future": "미래의 날짜와 시간을 선택하세요.",
"schedule_send_too_late": "이 시간은 서버가 허용하는 범위를 벗어납니다.",
"schedule_send_unsupported": "이 계정은 예약 보내기를 지원하지 않습니다.",
"schedule_send_cleanup_warning": "예약 보내기는 생성되었지만 초안 정리에 실패했습니다.",
"send_delay_unsupported": "이 계정은 보내기 지연을 지원하지 않습니다.",
"send_delay_unsupported_confirm": "이 계정은 보내기 지연을 지원하지 않습니다. 바로 보낼까요?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "확인",
@@ -923,6 +955,8 @@
"reenable": "다시 등록",
"relay_desc": "기본값은 호스팅된 Bulwark 릴레이입니다. 셀프 호스팅 시에만 변경하세요.",
"relay_label": "푸시 릴레이",
"relay_locked": "관리자에 의해 설정됨",
"relay_locked_desc": "푸시 릴레이 URL은 관리자가 설정했으며 변경할 수 없어요.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "이 기기에서 활성",
"status_busy": "처리 중…",
@@ -978,6 +1012,7 @@
"label": "삭제 동작",
"description": "메일을 삭제할 때의 동작을 선택해 주세요",
"trash": "휴지통으로 이동",
"trash_and_read": "휴지통으로 이동하고 읽음으로 표시",
"permanent": "영구 삭제",
"warning": "메일이 영구적으로 삭제되며 복구할 수 없어요. 주의해서 사용해 주세요."
},
@@ -1134,6 +1169,13 @@
"attachment_image_previews": {
"label": "첨부 파일에 이미지 미리보기 표시",
"description": "이미지 첨부 파일을 일반 파일 아이콘 대신 썸네일 카드로 표시합니다"
},
"send_delay": {
"label": "보내기 취소 / 보내기 지연",
"description": "일반 보내기를 서버 측 짧은 시간 동안 지연합니다.",
"off": "끔",
"seconds": "{seconds}초",
"unsupported": "현재 계정은 지연 보내기 지원을 알리지 않습니다. 설정은 다른 계정을 위해 계속 저장됩니다."
}
},
"composer": {
@@ -1803,7 +1845,11 @@
"color_tag": "태그",
"remove_color": "태그 제거",
"items_selected": "{count}개의 메일 선택됨",
"edit_draft": "임시보관 메일 수정"
"edit_draft": "임시보관 메일 수정",
"cancel_scheduled_send": "보내기 취소",
"reschedule_send": "다시 예약",
"cancel_and_edit": "취소하고 편집",
"cancel_and_compose_again": "취소하고 다시 작성"
},
"mailbox_context_menu": {
"mark_folder_read": "폴더를 읽음으로 표시",
@@ -1881,6 +1927,8 @@
"expand_collapse": "대화 펼치기/접기"
},
"composer": {
"send": "이메일 보내기",
"schedule_send": "보내기 예약",
"template_picker": "템플릿 선택기 열기"
}
},
+53 -5
View File
@@ -132,7 +132,8 @@
"mail": "Pasts",
"nav_label": "Navigācija",
"add_app": "Lietotnes",
"shared": "Koplietots"
"shared": "Koplietots",
"scheduled": "Ieplānots"
},
"protocol_handlers": {
"title": "Noklusējuma lietotnes",
@@ -239,7 +240,16 @@
"confirm_button": "Iztīrīt mapi",
"junk_hint": "Jūs varat iztīrīt mapi Mēstules, lai neatgriezeniski dzēstu visus ziņojumus.",
"trash_hint": "Jūs varat iztīrīt Atkritni, lai neatgriezeniski dzēstu visus ziņojumus."
}
},
"no_scheduled_emails": "Nav ieplānotu e-pastu",
"no_scheduled_emails_description": "Ziņojumi, kas ieplānoti vēlākai nosūtīšanai, būs redzami šeit.",
"scheduled_count": "{count, plural, one {1 ieplānots e-pasts} other {# ieplānoti e-pasti}}",
"scheduled_actions_hint": "Ieplānotos ziņojumus var atcelt, pārplānot vai rediģēt no ieplānotajām darbībām.",
"cancel_scheduled_send": "Atcelt sūtīšanu",
"reschedule_send": "Pārplānot",
"cancel_and_edit": "Atcelt un rediģēt",
"cancel_and_compose_again": "Atcelt un rakstīt no jauna",
"reschedule_prompt": "Ievadiet jaunu datumu/laiku, piem. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Nav atlasīta neviena vēstule",
@@ -514,7 +524,17 @@
"ai_verdict": "AI vērtējums",
"account": "Konts",
"no_subject": "(bez temata)"
}
},
"scheduled_banner": "Ieplānots nosūtīšanai {date}",
"scheduled_send_created": "E-pasts ieplānots nosūtīšanai",
"cancel_scheduled_send": "Atcelt sūtīšanu",
"reschedule_send": "Pārplānot",
"cancel_and_edit": "Atcelt un rediģēt",
"cancel_and_compose_again": "Atcelt un rakstīt no jauna",
"reschedule_prompt": "Ievadiet jaunu datumu/laiku, piem. 2026-05-04T15:30",
"scheduled_actions_only": "Ieplānoto skatā ir pieejamas tikai ieplānošanas darbības",
"undo_send_scheduled": "Ziņojums ir ieplānots nosūtīšanai",
"undo_send": "Atsaukt sūtīšanu"
},
"email_composer": {
"new_message": "Jauns ziņojums",
@@ -607,7 +627,19 @@
},
"add_link": "Pievienot saiti",
"link_url_prompt": "Ievadiet URL",
"sending": "Sūta..."
"sending": "Sūta...",
"schedule_send": "Ieplānot sūtīšanu",
"schedule_send_description": "Izvēlieties, kad serverim jānosūta šis ziņojums.",
"schedule_send_required": "Izvēlieties datumu un laiku.",
"schedule_send_invalid": "Ievadiet derīgu datumu un laiku.",
"schedule_send_future": "Izvēlieties datumu un laiku nākotnē.",
"schedule_send_too_late": "Šis laiks ir vēlāk, nekā serveris atļauj.",
"schedule_send_unsupported": "Šim kontam ieplānota sūtīšana netiek atbalstīta.",
"schedule_send_cleanup_warning": "Ieplānotā sūtīšana tika izveidota, bet melnraksta tīrīšana neizdevās.",
"send_delay_unsupported": "Šim kontam sūtīšanas aizture netiek atbalstīta.",
"send_delay_unsupported_confirm": "Šis konts neatbalsta sūtīšanas aizturi. Sūtīt uzreiz?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Apstiprināt",
@@ -923,6 +955,8 @@
"reenable": "Reģistrēt vēlreiz",
"relay_desc": "Pēc noklusējuma izmanto izmitināto Bulwark releju. Mainiet tikai tad, ja izmitināt pats.",
"relay_label": "Push relejs",
"relay_locked": "Iestatījis administrators",
"relay_locked_desc": "Push releja URL ir iestatījis administrators, un to nevar mainīt.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Aktīvs šajā ierīcē",
"status_busy": "Notiek darbs…",
@@ -978,6 +1012,7 @@
"label": "Darbība dzēšot",
"description": "Kas notiek, izdzēšot vēstuli",
"trash": "Pārvietot uz atkritni",
"trash_and_read": "Pārvietot uz atkritni un atzīmēt kā lasītu",
"permanent": "Dzēst neatgriezeniski",
"warning": "Vēstules tiks dzēstas neatgriezeniski. Šī darbība nav atceļama."
},
@@ -1134,6 +1169,13 @@
"attachment_image_previews": {
"label": "Rādīt attēlu priekšskatījumus pielikumos",
"description": "Rādīt attēlu pielikumus kā sīktēlu kartītes, nevis vispārīgas failu ikonas"
},
"send_delay": {
"label": "Atsaukt sūtīšanu / sūtīšanas aizture",
"description": "Aizturiet parastu sūtīšanu īsā servera puses logā.",
"off": "Izslēgts",
"seconds": "{seconds} sekundes",
"unsupported": "Pašreizējais konts neziņo par aizturētas sūtīšanas atbalstu. Iestatījums joprojām tiks saglabāts citiem kontiem."
}
},
"composer": {
@@ -1803,7 +1845,11 @@
"color_tag": "Tags",
"remove_color": "Noņemt tagu",
"items_selected": "{count} vēstules atlasītas",
"edit_draft": "Rediģēt melnrakstu"
"edit_draft": "Rediģēt melnrakstu",
"cancel_scheduled_send": "Atcelt sūtīšanu",
"reschedule_send": "Pārplānot",
"cancel_and_edit": "Atcelt un rediģēt",
"cancel_and_compose_again": "Atcelt un rakstīt no jauna"
},
"mailbox_context_menu": {
"mark_folder_read": "Atzīmēt mapi kā lasītu",
@@ -1881,6 +1927,8 @@
"expand_collapse": "Izvērst/sairt sarunu"
},
"composer": {
"send": "Nosūtīt e-pastu",
"schedule_send": "Ieplānot nosūtīšanu",
"template_picker": "Atvērt veidņu izvēli"
}
},
+53 -5
View File
@@ -132,7 +132,8 @@
"mail": "E-mail",
"nav_label": "Navigatie",
"add_app": "Apps",
"shared": "Gedeeld"
"shared": "Gedeeld",
"scheduled": "Gepland"
},
"protocol_handlers": {
"title": "Standaardapps",
@@ -239,7 +240,16 @@
"confirm_button": "Map legen",
"junk_hint": "U kunt de map Spam legen om alle berichten permanent te verwijderen.",
"trash_hint": "U kunt de prullenbak legen om alle berichten permanent te verwijderen."
}
},
"no_scheduled_emails": "Geen geplande e-mails",
"no_scheduled_emails_description": "Berichten die voor later zijn gepland verschijnen hier.",
"scheduled_count": "{count, plural, one {1 geplande e-mail} other {# geplande e-mails}}",
"scheduled_actions_hint": "Geplande berichten kunnen worden geannuleerd, opnieuw gepland of bewerkt via hun geplande acties.",
"cancel_scheduled_send": "Verzenden annuleren",
"reschedule_send": "Opnieuw plannen",
"cancel_and_edit": "Annuleren en bewerken",
"cancel_and_compose_again": "Annuleren en opnieuw opstellen",
"reschedule_prompt": "Voer een nieuwe datum/tijd in, bijv. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Geen e-mail geselecteerd",
@@ -514,7 +524,17 @@
"ai_verdict": "AI-oordeel",
"account": "Account",
"no_subject": "(geen onderwerp)"
}
},
"scheduled_banner": "Gepland om te verzenden op {date}",
"scheduled_send_created": "E-mail gepland voor verzending",
"cancel_scheduled_send": "Verzenden annuleren",
"reschedule_send": "Opnieuw plannen",
"cancel_and_edit": "Annuleren en bewerken",
"cancel_and_compose_again": "Annuleren en opnieuw opstellen",
"reschedule_prompt": "Voer een nieuwe datum/tijd in, bijv. 2026-05-04T15:30",
"scheduled_actions_only": "De geplande weergave ondersteunt alleen geplande acties",
"undo_send_scheduled": "Bericht gepland voor verzending",
"undo_send": "Verzenden ongedaan maken"
},
"email_composer": {
"new_message": "Nieuw bericht",
@@ -607,7 +627,19 @@
},
"add_link": "Link toevoegen",
"link_url_prompt": "Voer de URL in",
"sending": "Bezig met verzenden..."
"sending": "Bezig met verzenden...",
"schedule_send": "Verzenden plannen",
"schedule_send_description": "Kies wanneer de server dit bericht moet vrijgeven.",
"schedule_send_required": "Kies een datum en tijd.",
"schedule_send_invalid": "Voer een geldige datum en tijd in.",
"schedule_send_future": "Kies een toekomstige datum en tijd.",
"schedule_send_too_late": "Dit tijdstip is later dan de server toestaat.",
"schedule_send_unsupported": "Gepland verzenden wordt niet ondersteund voor dit account.",
"schedule_send_cleanup_warning": "Gepland verzenden is aangemaakt, maar het opschonen van de conceptversie is mislukt.",
"send_delay_unsupported": "Verzendvertraging wordt niet ondersteund voor dit account.",
"send_delay_unsupported_confirm": "Dit account ondersteunt geen verzendvertraging. Meteen verzenden?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Bevestigen",
@@ -923,6 +955,8 @@
"reenable": "Opnieuw registreren",
"relay_desc": "Gebruikt standaard de gehoste Bulwark-relay. Wijzig alleen als je zelf hostt.",
"relay_label": "Push-relay",
"relay_locked": "Ingesteld door beheerder",
"relay_locked_desc": "De push-relay-URL is ingesteld door je beheerder en kan niet worden gewijzigd.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Actief op dit apparaat",
"status_busy": "Bezig…",
@@ -978,6 +1012,7 @@
"label": "Verwijderactie",
"description": "Wat er gebeurt wanneer je een e-mail verwijdert",
"trash": "Verplaatsen naar prullenbak",
"trash_and_read": "Verplaatsen naar prullenbak en als gelezen markeren",
"permanent": "Permanent verwijderen",
"warning": "E-mails worden permanent verwijderd en kunnen niet worden hersteld. Deze actie is onomkeerbaar."
},
@@ -1134,6 +1169,13 @@
"attachment_image_previews": {
"label": "Voorbeelden van afbeeldingen tonen in bijlagen",
"description": "Toon afbeeldingsbijlagen als miniatuurkaarten in plaats van generieke bestandspictogrammen"
},
"send_delay": {
"label": "Verzenden ongedaan maken / verzendvertraging",
"description": "Vertraag normale verzending met een korte server-side periode.",
"off": "Uit",
"seconds": "{seconds} seconden",
"unsupported": "Het huidige account meldt geen ondersteuning voor vertraagd verzenden. De instelling blijft opgeslagen voor andere accounts."
}
},
"composer": {
@@ -1803,7 +1845,11 @@
"color_tag": "Label",
"remove_color": "Label verwijderen",
"items_selected": "{count} e-mails geselecteerd",
"edit_draft": "Concept bewerken"
"edit_draft": "Concept bewerken",
"cancel_scheduled_send": "Verzenden annuleren",
"reschedule_send": "Opnieuw plannen",
"cancel_and_edit": "Annuleren en bewerken",
"cancel_and_compose_again": "Annuleren en opnieuw opstellen"
},
"mailbox_context_menu": {
"mark_folder_read": "Map markeren als gelezen",
@@ -1881,6 +1927,8 @@
"expand_collapse": "Gesprek uitklappen/inklappen"
},
"composer": {
"send": "E-mail verzenden",
"schedule_send": "Verzenden plannen",
"template_picker": "Sjabloonkiezer openen"
}
},
+53 -5
View File
@@ -132,7 +132,8 @@
"mail": "Poczta",
"nav_label": "Nawigacja",
"add_app": "Aplikacje",
"shared": "Udostępnione"
"shared": "Udostępnione",
"scheduled": "Zaplanowane"
},
"protocol_handlers": {
"title": "Aplikacje domyślne",
@@ -239,7 +240,16 @@
"confirm_button": "Opróżnij folder",
"junk_hint": "Możesz opróżnić folder Spam, aby trwale usunąć wszystkie wiadomości.",
"trash_hint": "Możesz opróżnić Kosz, aby trwale usunąć wszystkie wiadomości."
}
},
"no_scheduled_emails": "Brak zaplanowanych e-maili",
"no_scheduled_emails_description": "Wiadomości zaplanowane na później pojawią się tutaj.",
"scheduled_count": "{count, plural, one {1 zaplanowany e-mail} few {# zaplanowane e-maile} other {# zaplanowanych e-maili}}",
"scheduled_actions_hint": "Zaplanowane wiadomości można anulować, przełożyć lub edytować z poziomu akcji planowania.",
"cancel_scheduled_send": "Anuluj wysyłkę",
"reschedule_send": "Przełóż",
"cancel_and_edit": "Anuluj i edytuj",
"cancel_and_compose_again": "Anuluj i napisz ponownie",
"reschedule_prompt": "Wprowadź nową datę/godzinę, np. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Nie wybrano wiadomości",
@@ -514,7 +524,17 @@
"ai_verdict": "Werdykt AI",
"account": "Konto",
"no_subject": "(brak tematu)"
}
},
"scheduled_banner": "Zaplanowano wysyłkę na {date}",
"scheduled_send_created": "E-mail zaplanowany do wysłania",
"cancel_scheduled_send": "Anuluj wysyłkę",
"reschedule_send": "Przełóż",
"cancel_and_edit": "Anuluj i edytuj",
"cancel_and_compose_again": "Anuluj i napisz ponownie",
"reschedule_prompt": "Wprowadź nową datę/godzinę, np. 2026-05-04T15:30",
"scheduled_actions_only": "Widok Zaplanowane obsługuje tylko akcje planowania",
"undo_send_scheduled": "Wiadomość zaplanowana do wysłania",
"undo_send": "Cofnij wysyłkę"
},
"email_composer": {
"new_message": "Nowa wiadomość",
@@ -607,7 +627,19 @@
},
"add_link": "Dodaj link",
"link_url_prompt": "Wprowadź adres URL",
"sending": "Wysyłanie..."
"sending": "Wysyłanie...",
"schedule_send": "Zaplanuj wysyłkę",
"schedule_send_description": "Wybierz, kiedy serwer ma wysłać tę wiadomość.",
"schedule_send_required": "Wybierz datę i godzinę.",
"schedule_send_invalid": "Wprowadź prawidłową datę i godzinę.",
"schedule_send_future": "Wybierz datę i godzinę w przyszłości.",
"schedule_send_too_late": "Ten czas jest późniejszy, niż pozwala serwer.",
"schedule_send_unsupported": "Zaplanowana wysyłka nie jest obsługiwana dla tego konta.",
"schedule_send_cleanup_warning": "Zaplanowana wysyłka została utworzona, ale czyszczenie wersji roboczej nie powiodło się.",
"send_delay_unsupported": "Opóźnienie wysyłki nie jest obsługiwane dla tego konta.",
"send_delay_unsupported_confirm": "To konto nie obsługuje opóźnienia wysyłki. Wysłać natychmiast?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Potwierdź",
@@ -923,6 +955,8 @@
"reenable": "Zarejestruj ponownie",
"relay_desc": "Domyślnie używa hostowanego przekaźnika Bulwark. Zmień tylko jeśli hostujesz samodzielnie.",
"relay_label": "Przekaźnik push",
"relay_locked": "Ustawione przez administratora",
"relay_locked_desc": "Adres URL przekaźnika push został ustawiony przez administratora i nie można go zmienić.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Aktywne na tym urządzeniu",
"status_busy": "Przetwarzanie…",
@@ -978,6 +1012,7 @@
"label": "Akcja usuwania",
"description": "Co ma się stać po usunięciu wiadomości e-mail",
"trash": "Przenieś do kosza",
"trash_and_read": "Przenieś do kosza i oznacz jako przeczytane",
"permanent": "Usuń trwale",
"warning": "Wiadomości e-mail zostaną trwale usunięte i nie będzie można ich odzyskać. Ta akcja jest nieodwracalna."
},
@@ -1134,6 +1169,13 @@
"attachment_image_previews": {
"label": "Pokaż podgląd obrazów w załącznikach",
"description": "Wyświetlaj załączone obrazy jako miniatury zamiast ogólnych ikon plików"
},
"send_delay": {
"label": "Cofnij wysyłkę / opóźnienie wysyłki",
"description": "Opóźnia normalne wysyłanie krótkim oknem po stronie serwera.",
"off": "Wyłączone",
"seconds": "{seconds} sekund",
"unsupported": "Bieżące konto nie zgłasza obsługi opóźnionej wysyłki. Ustawienie pozostanie zapisane dla innych kont."
}
},
"composer": {
@@ -1803,7 +1845,11 @@
"color_tag": "Etykieta",
"remove_color": "Usuń etykietę",
"items_selected": "{count} zaznaczonych wiadomości",
"edit_draft": "Edytuj szkic"
"edit_draft": "Edytuj szkic",
"cancel_scheduled_send": "Anuluj wysyłkę",
"reschedule_send": "Przełóż",
"cancel_and_edit": "Anuluj i edytuj",
"cancel_and_compose_again": "Anuluj i napisz ponownie"
},
"mailbox_context_menu": {
"mark_folder_read": "Oznacz folder jako przeczytany",
@@ -1881,6 +1927,8 @@
"expand_collapse": "Rozwiń/zwiń wątek"
},
"composer": {
"send": "Wyślij e-mail",
"schedule_send": "Zaplanuj wysyłkę",
"template_picker": "Otwórz wybór szablonu"
}
},
+53 -5
View File
@@ -132,7 +132,8 @@
"mail": "E-mail",
"nav_label": "Navegação",
"add_app": "Apps",
"shared": "Compartilhado"
"shared": "Compartilhado",
"scheduled": "Agendados"
},
"protocol_handlers": {
"title": "Aplicativos padrão",
@@ -239,7 +240,16 @@
"confirm_button": "Esvaziar pasta",
"junk_hint": "Você pode esvaziar a pasta de Spam para remover permanentemente todas as mensagens.",
"trash_hint": "Você pode esvaziar a Lixeira para remover permanentemente todas as mensagens."
}
},
"no_scheduled_emails": "Nenhum e-mail agendado",
"no_scheduled_emails_description": "Mensagens agendadas para mais tarde aparecerão aqui.",
"scheduled_count": "{count, plural, one {1 e-mail agendado} other {# e-mails agendados}}",
"scheduled_actions_hint": "Mensagens agendadas podem ser canceladas, reagendadas ou editadas pelas ações de agendamento.",
"cancel_scheduled_send": "Cancelar envio",
"reschedule_send": "Reagendar",
"cancel_and_edit": "Cancelar e editar",
"cancel_and_compose_again": "Cancelar e escrever novamente",
"reschedule_prompt": "Informe uma nova data/hora, ex. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Nenhum e-mail selecionado",
@@ -514,7 +524,17 @@
"ai_verdict": "Veredito da IA",
"account": "Conta",
"no_subject": "(sem assunto)"
}
},
"scheduled_banner": "Agendado para enviar em {date}",
"scheduled_send_created": "E-mail agendado para envio",
"cancel_scheduled_send": "Cancelar envio",
"reschedule_send": "Reagendar",
"cancel_and_edit": "Cancelar e editar",
"cancel_and_compose_again": "Cancelar e escrever novamente",
"reschedule_prompt": "Informe uma nova data/hora, ex. 2026-05-04T15:30",
"scheduled_actions_only": "A visualização Agendados permite apenas ações de agendamento",
"undo_send_scheduled": "Mensagem agendada para envio",
"undo_send": "Desfazer envio"
},
"email_composer": {
"new_message": "Nova Mensagem",
@@ -607,7 +627,19 @@
},
"add_link": "Adicionar link",
"link_url_prompt": "Insira a URL",
"sending": "Enviando..."
"sending": "Enviando...",
"schedule_send": "Agendar envio",
"schedule_send_description": "Escolha quando o servidor deve liberar esta mensagem.",
"schedule_send_required": "Escolha uma data e hora.",
"schedule_send_invalid": "Informe uma data e hora válidas.",
"schedule_send_future": "Escolha uma data e hora no futuro.",
"schedule_send_too_late": "Este horário é posterior ao permitido pelo servidor.",
"schedule_send_unsupported": "Envio agendado não é compatível com esta conta.",
"schedule_send_cleanup_warning": "O envio agendado foi criado, mas a limpeza do rascunho falhou.",
"send_delay_unsupported": "Atraso de envio não é compatível com esta conta.",
"send_delay_unsupported_confirm": "Esta conta não oferece atraso de envio. Enviar imediatamente?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Confirmar",
@@ -923,6 +955,8 @@
"reenable": "Registrar novamente",
"relay_desc": "Usa o relay Bulwark hospedado por padrão. Altere apenas se você hospedar.",
"relay_label": "Relay push",
"relay_locked": "Definido pelo administrador",
"relay_locked_desc": "A URL do relay push foi definida pelo seu administrador e não pode ser alterada.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Ativo neste dispositivo",
"status_busy": "Processando…",
@@ -978,6 +1012,7 @@
"label": "Ação de Exclusão",
"description": "O que acontece quando você exclui um e-mail",
"trash": "Mover para Lixeira",
"trash_and_read": "Mover para Lixeira e marcar como lida",
"permanent": "Excluir Permanentemente",
"warning": "Os e-mails serão excluídos permanentemente e não poderão ser recuperados. Esta ação é irreversível."
},
@@ -1134,6 +1169,13 @@
"attachment_image_previews": {
"label": "Mostrar prévias de imagens em anexos",
"description": "Renderizar anexos de imagem como miniaturas em vez de ícones genéricos de arquivo"
},
"send_delay": {
"label": "Desfazer envio / atraso de envio",
"description": "Atrase envios normais com uma breve janela no servidor.",
"off": "Desativado",
"seconds": "{seconds} segundos",
"unsupported": "A conta atual não anuncia suporte a envio atrasado. A configuração continuará salva para outras contas."
}
},
"composer": {
@@ -1803,7 +1845,11 @@
"color_tag": "Etiqueta",
"remove_color": "Remover etiqueta",
"items_selected": "{count} e-mails selecionados",
"edit_draft": "Editar rascunho"
"edit_draft": "Editar rascunho",
"cancel_scheduled_send": "Cancelar envio",
"reschedule_send": "Reagendar",
"cancel_and_edit": "Cancelar e editar",
"cancel_and_compose_again": "Cancelar e escrever novamente"
},
"mailbox_context_menu": {
"mark_folder_read": "Marcar pasta como lida",
@@ -1881,6 +1927,8 @@
"expand_collapse": "Expandir/recolher conversa"
},
"composer": {
"send": "Enviar e-mail",
"schedule_send": "Agendar envio",
"template_picker": "Abrir seletor de modelos"
}
},
+53 -5
View File
@@ -132,7 +132,8 @@
"mail": "Почта",
"nav_label": "Навигация",
"add_app": "Приложения",
"shared": "Общие"
"shared": "Общие",
"scheduled": "Запланировано"
},
"protocol_handlers": {
"title": "Приложения по умолчанию",
@@ -239,7 +240,16 @@
"confirm_button": "Очистить папку",
"junk_hint": "Вы можете очистить папку Спам для постоянного удаления всех сообщений.",
"trash_hint": "Вы можете очистить Корзину для постоянного удаления всех сообщений."
}
},
"no_scheduled_emails": "Нет запланированных писем",
"no_scheduled_emails_description": "Сообщения, запланированные на позже, появятся здесь.",
"scheduled_count": "{count, plural, one {# запланированное письмо} few {# запланированных письма} other {# запланированных писем}}",
"scheduled_actions_hint": "Запланированные сообщения можно отменить, перенести или изменить через действия планирования.",
"cancel_scheduled_send": "Отменить отправку",
"reschedule_send": "Перенести",
"cancel_and_edit": "Отменить и изменить",
"cancel_and_compose_again": "Отменить и написать заново",
"reschedule_prompt": "Введите новую дату/время, например 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Письмо не выбрано",
@@ -514,7 +524,17 @@
"ai_verdict": "Вердикт ИИ",
"account": "Аккаунт",
"no_subject": "(без темы)"
}
},
"scheduled_banner": "Запланировано к отправке {date}",
"scheduled_send_created": "Письмо запланировано к отправке",
"cancel_scheduled_send": "Отменить отправку",
"reschedule_send": "Перенести",
"cancel_and_edit": "Отменить и изменить",
"cancel_and_compose_again": "Отменить и написать заново",
"reschedule_prompt": "Введите новую дату/время, например 2026-05-04T15:30",
"scheduled_actions_only": "В представлении запланированных доступны только действия планирования",
"undo_send_scheduled": "Сообщение запланировано к отправке",
"undo_send": "Отменить отправку"
},
"email_composer": {
"new_message": "Новое письмо",
@@ -607,7 +627,19 @@
},
"add_link": "Добавить ссылку",
"link_url_prompt": "Введите URL",
"sending": "Отправка..."
"sending": "Отправка...",
"schedule_send": "Запланировать отправку",
"schedule_send_description": "Выберите, когда сервер должен отправить это сообщение.",
"schedule_send_required": "Выберите дату и время.",
"schedule_send_invalid": "Введите действительные дату и время.",
"schedule_send_future": "Выберите дату и время в будущем.",
"schedule_send_too_late": "Это время позже, чем разрешает сервер.",
"schedule_send_unsupported": "Запланированная отправка не поддерживается для этой учетной записи.",
"schedule_send_cleanup_warning": "Запланированная отправка создана, но очистка черновика не удалась.",
"send_delay_unsupported": "Задержка отправки не поддерживается для этой учетной записи.",
"send_delay_unsupported_confirm": "Эта учетная запись не поддерживает задержку отправки. Отправить сразу?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Подтвердить",
@@ -923,6 +955,8 @@
"reenable": "Перерегистрировать",
"relay_desc": "По умолчанию используется размещённый релей Bulwark. Меняйте только если хостите самостоятельно.",
"relay_label": "Push-релей",
"relay_locked": "Установлено администратором",
"relay_locked_desc": "URL push-релея установлен вашим администратором и не может быть изменён.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Активно на этом устройстве",
"status_busy": "Выполняется…",
@@ -978,6 +1012,7 @@
"label": "Действие при удалении",
"description": "Что происходит при удалении письма",
"trash": "Переместить в корзину",
"trash_and_read": "Переместить в корзину и пометить как прочитанное",
"permanent": "Удалить навсегда",
"warning": "Письма будут удалены навсегда и не могут быть восстановлены. Это действие необратимо."
},
@@ -1134,6 +1169,13 @@
"attachment_image_previews": {
"label": "Показывать миниатюры изображений во вложениях",
"description": "Отображать вложенные изображения в виде миниатюр вместо обычных значков файлов"
},
"send_delay": {
"label": "Отмена отправки / задержка отправки",
"description": "Задерживает обычную отправку на короткий серверный интервал.",
"off": "Выкл.",
"seconds": "{seconds} сек.",
"unsupported": "Текущая учетная запись не заявляет поддержку отложенной отправки. Настройка сохранится для других учетных записей."
}
},
"composer": {
@@ -1803,7 +1845,11 @@
"color_tag": "Тег",
"remove_color": "Удалить тег",
"items_selected": "{count} писем выбрано",
"edit_draft": "Редактировать черновик"
"edit_draft": "Редактировать черновик",
"cancel_scheduled_send": "Отменить отправку",
"reschedule_send": "Перенести",
"cancel_and_edit": "Отменить и изменить",
"cancel_and_compose_again": "Отменить и написать заново"
},
"mailbox_context_menu": {
"mark_folder_read": "Отметить папку как прочитанную",
@@ -1881,6 +1927,8 @@
"expand_collapse": "Развернуть/свернуть цепочку"
},
"composer": {
"send": "Отправить письмо",
"schedule_send": "Запланировать отправку",
"template_picker": "Открыть выбор шаблонов"
}
},
+53 -5
View File
@@ -132,7 +132,8 @@
"shared": "Paylaşılan",
"mail": "Posta",
"nav_label": "Gezinme",
"add_app": "Uygulamalar"
"add_app": "Uygulamalar",
"scheduled": "Zamanlandı"
},
"protocol_handlers": {
"title": "Varsayılan uygulamalar",
@@ -239,7 +240,16 @@
"confirm_button": "Klasörü boşalt",
"junk_hint": "Tüm iletileri kalıcı olarak kaldırmak için Önemsiz klasörünü boşaltabilirsiniz.",
"trash_hint": "Tüm iletileri kalıcı olarak kaldırmak için Çöp Kutusu klasörünü boşaltabilirsiniz."
}
},
"no_scheduled_emails": "Zamanlanmış e-posta yok",
"no_scheduled_emails_description": "Daha sonra gönderilecek iletiler burada görünür.",
"scheduled_count": "{count, plural, one {1 zamanlanmış e-posta} other {# zamanlanmış e-posta}}",
"scheduled_actions_hint": "Zamanlanmış iletiler zamanlama işlemlerinden iptal edilebilir, yeniden zamanlanabilir veya düzenlenebilir.",
"cancel_scheduled_send": "Göndermeyi iptal et",
"reschedule_send": "Yeniden zamanla",
"cancel_and_edit": "İptal et ve düzenle",
"cancel_and_compose_again": "İptal et ve yeniden yaz",
"reschedule_prompt": "Yeni tarih/saat girin, ör. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "E-posta seçilmedi",
@@ -514,7 +524,17 @@
"ai_verdict": "Yapay zeka kararı",
"account": "Hesap",
"no_subject": "(konu yok)"
}
},
"scheduled_banner": "{date} tarihinde gönderilmek üzere zamanlandı",
"scheduled_send_created": "E-posta gönderim için zamanlandı",
"cancel_scheduled_send": "Göndermeyi iptal et",
"reschedule_send": "Yeniden zamanla",
"cancel_and_edit": "İptal et ve düzenle",
"cancel_and_compose_again": "İptal et ve yeniden yaz",
"reschedule_prompt": "Yeni tarih/saat girin, ör. 2026-05-04T15:30",
"scheduled_actions_only": "Zamanlandı görünümü yalnızca zamanlama işlemlerini destekler",
"undo_send_scheduled": "İleti gönderim için zamanlandı",
"undo_send": "Göndermeyi geri al"
},
"email_composer": {
"new_message": "Yeni İleti",
@@ -607,7 +627,19 @@
"message": "İletinizde \"{keyword}\" geçiyor ancak hiçbir dosya eklenmemiş. Yine de gönderilsin mi?",
"send_anyway": "Yine de gönder",
"back": "Düzenlemeye dön"
}
},
"schedule_send": "Göndermeyi zamanla",
"schedule_send_description": "Sunucunun bu iletiyi ne zaman göndereceğini seçin.",
"schedule_send_required": "Bir tarih ve saat seçin.",
"schedule_send_invalid": "Geçerli bir tarih ve saat girin.",
"schedule_send_future": "Gelecekte bir tarih ve saat seçin.",
"schedule_send_too_late": "Bu zaman sunucunun izin verdiğinden daha geç.",
"schedule_send_unsupported": "Bu hesap için zamanlanmış gönderim desteklenmiyor.",
"schedule_send_cleanup_warning": "Zamanlanmış gönderim oluşturuldu, ancak taslak temizliği başarısız oldu.",
"send_delay_unsupported": "Bu hesap için gönderim gecikmesi desteklenmiyor.",
"send_delay_unsupported_confirm": "Bu hesap gönderim gecikmesini desteklemiyor. Hemen gönderilsin mi?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Onayla",
@@ -923,6 +955,8 @@
"reenable": "Yeniden kaydet",
"relay_desc": "Varsayılan olarak barındırılan Bulwark rölesini kullanır. Yalnızca kendiniz barındırıyorsanız değiştirin.",
"relay_label": "Push röle",
"relay_locked": "Yönetici tarafından ayarlandı",
"relay_locked_desc": "Push röle URL'si yöneticiniz tarafından ayarlandı ve değiştirilemez.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Bu cihazda etkin",
"status_busy": "Çalışıyor…",
@@ -978,6 +1012,7 @@
"label": "Silme İşlemi",
"description": "Bir e-postayı sildiğinizde ne olacağı",
"trash": "Çöp Kutusuna Taşı",
"trash_and_read": "Çöp Kutusuna Taşı ve okundu olarak işaretle",
"permanent": "Kalıcı Olarak Sil",
"warning": "E-postalar kalıcı olarak silinecek ve kurtarılamayacak. Bu işlem geri alınamaz."
},
@@ -1134,6 +1169,13 @@
"attachment_image_previews": {
"label": "Eklerde resim önizlemelerini göster",
"description": "Resim eklerini, genel dosya simgeleri yerine küçük resim kartları olarak göster"
},
"send_delay": {
"label": "Göndermeyi geri al / gönderim gecikmesi",
"description": "Normal gönderimleri sunucu tarafında kısa bir süre geciktirin.",
"off": "Kapalı",
"seconds": "{seconds} saniye",
"unsupported": "Geçerli hesap gecikmeli gönderim desteği bildirmiyor. Ayar diğer hesaplar için kaydedilmeye devam eder."
}
},
"composer": {
@@ -1803,7 +1845,11 @@
"color_tag": "Etiket",
"remove_color": "Etiketi kaldır",
"items_selected": "{count} e-posta seçildi",
"edit_draft": "Taslağı Düzenle"
"edit_draft": "Taslağı Düzenle",
"cancel_scheduled_send": "Göndermeyi iptal et",
"reschedule_send": "Yeniden zamanla",
"cancel_and_edit": "İptal et ve düzenle",
"cancel_and_compose_again": "İptal et ve yeniden yaz"
},
"mailbox_context_menu": {
"mark_folder_read": "Klasörü okundu olarak işaretle",
@@ -1881,6 +1927,8 @@
"expand_collapse": "İleti dizisini genişlet/daralt"
},
"composer": {
"send": "E-posta gönder",
"schedule_send": "Göndermeyi planla",
"template_picker": "Şablon seçiciyi aç"
}
},
+53 -5
View File
@@ -132,7 +132,8 @@
"mail": "Пошта",
"nav_label": "Навігація",
"add_app": "програми",
"shared": "Спільні"
"shared": "Спільні",
"scheduled": "Заплановано"
},
"protocol_handlers": {
"title": "Програми за замовчуванням",
@@ -239,7 +240,16 @@
"confirm_button": "Порожня папка",
"junk_hint": "Ви можете очистити папку «Сміття», щоб остаточно видалити всі повідомлення.",
"trash_hint": "Ви можете очистити папку «Кошик», щоб остаточно видалити всі повідомлення."
}
},
"no_scheduled_emails": "Немає запланованих листів",
"no_scheduled_emails_description": "Повідомлення, заплановані на пізніше, з’являться тут.",
"scheduled_count": "{count, plural, one {# запланований лист} few {# заплановані листи} other {# запланованих листів}}",
"scheduled_actions_hint": "Заплановані повідомлення можна скасувати, перенести або редагувати через дії планування.",
"cancel_scheduled_send": "Скасувати надсилання",
"reschedule_send": "Перенести",
"cancel_and_edit": "Скасувати й редагувати",
"cancel_and_compose_again": "Скасувати й написати знову",
"reschedule_prompt": "Введіть нову дату/час, напр. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Електронна адреса не вибрана",
@@ -514,7 +524,17 @@
"ai_verdict": "Висновок ШІ",
"account": "Обліковий запис",
"no_subject": "(без теми)"
}
},
"scheduled_banner": "Заплановано на надсилання {date}",
"scheduled_send_created": "Лист заплановано до надсилання",
"cancel_scheduled_send": "Скасувати надсилання",
"reschedule_send": "Перенести",
"cancel_and_edit": "Скасувати й редагувати",
"cancel_and_compose_again": "Скасувати й написати знову",
"reschedule_prompt": "Введіть нову дату/час, напр. 2026-05-04T15:30",
"scheduled_actions_only": "У поданні Заплановано доступні лише дії планування",
"undo_send_scheduled": "Повідомлення заплановано до надсилання",
"undo_send": "Скасувати надсилання"
},
"email_composer": {
"new_message": "Нове повідомлення",
@@ -607,7 +627,19 @@
},
"add_link": "Додати посилання",
"link_url_prompt": "Введіть URL",
"sending": "Надсилання..."
"sending": "Надсилання...",
"schedule_send": "Запланувати надсилання",
"schedule_send_description": "Виберіть, коли сервер має надіслати це повідомлення.",
"schedule_send_required": "Виберіть дату й час.",
"schedule_send_invalid": "Введіть дійсні дату й час.",
"schedule_send_future": "Виберіть дату й час у майбутньому.",
"schedule_send_too_late": "Цей час пізніший, ніж дозволяє сервер.",
"schedule_send_unsupported": "Заплановане надсилання не підтримується для цього облікового запису.",
"schedule_send_cleanup_warning": "Заплановане надсилання створено, але очищення чернетки не вдалося.",
"send_delay_unsupported": "Затримка надсилання не підтримується для цього облікового запису.",
"send_delay_unsupported_confirm": "Цей обліковий запис не підтримує затримку надсилання. Надіслати негайно?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Підтвердити",
@@ -923,6 +955,8 @@
"reenable": "Перереєструвати",
"relay_desc": "Типово використовується розміщене реле Bulwark. Змінюйте лише, якщо хостите самостійно.",
"relay_label": "Push-реле",
"relay_locked": "Встановлено адміністратором",
"relay_locked_desc": "URL push-реле встановлено вашим адміністратором і його не можна змінити.",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Активно на цьому пристрої",
"status_busy": "Виконується…",
@@ -978,6 +1012,7 @@
"label": "Видалити дію",
"description": "Що відбувається, коли ви видаляєте електронний лист",
"trash": "Перемістити в кошик",
"trash_and_read": "Перемістити в кошик і позначити як прочитане",
"permanent": "Видалити назавжди",
"warning": "Електронні листи буде видалено назавжди, і їх неможливо буде відновити. Ця дія незворотна."
},
@@ -1134,6 +1169,13 @@
"attachment_image_previews": {
"label": "Показувати попередній перегляд зображень у вкладеннях",
"description": "Відображати вкладені зображення як ескізи замість загальних піктограм файлів"
},
"send_delay": {
"label": "Скасувати надсилання / затримка надсилання",
"description": "Затримує звичайне надсилання на короткий серверний інтервал.",
"off": "Вимкнено",
"seconds": "{seconds} с",
"unsupported": "Поточний обліковий запис не повідомляє про підтримку відкладеного надсилання. Налаштування залишиться збереженим для інших облікових записів."
}
},
"composer": {
@@ -1803,7 +1845,11 @@
"color_tag": "Мітка",
"remove_color": "Видалити мітку",
"items_selected": "Вибрано електронних листів: {count}",
"edit_draft": "Редагувати чернетку"
"edit_draft": "Редагувати чернетку",
"cancel_scheduled_send": "Скасувати надсилання",
"reschedule_send": "Перенести",
"cancel_and_edit": "Скасувати й редагувати",
"cancel_and_compose_again": "Скасувати й написати знову"
},
"mailbox_context_menu": {
"mark_folder_read": "Позначити папку як прочитану",
@@ -1881,6 +1927,8 @@
"expand_collapse": "Розгорнути/згорнути ланцюжок"
},
"composer": {
"send": "Надіслати лист",
"schedule_send": "Запланувати надсилання",
"template_picker": "Відкрити засіб вибору шаблону"
}
},
+53 -5
View File
@@ -132,7 +132,8 @@
"mail": "邮件",
"nav_label": "导航",
"add_app": "应用",
"shared": "共享"
"shared": "共享",
"scheduled": "已计划"
},
"protocol_handlers": {
"title": "默认应用",
@@ -239,7 +240,16 @@
"confirm_button": "清空文件夹",
"junk_hint": "您可以清空垃圾邮件文件夹以永久删除所有邮件。",
"trash_hint": "您可以清空已删除文件夹以永久删除所有邮件。"
}
},
"no_scheduled_emails": "没有计划发送的邮件",
"no_scheduled_emails_description": "计划稍后发送的邮件会显示在这里。",
"scheduled_count": "{count, plural, one {1 封计划发送邮件} other {# 封计划发送邮件}}",
"scheduled_actions_hint": "计划发送的邮件可以通过计划操作取消、重新计划或编辑。",
"cancel_scheduled_send": "取消发送",
"reschedule_send": "重新计划",
"cancel_and_edit": "取消并编辑",
"cancel_and_compose_again": "取消并重新撰写",
"reschedule_prompt": "输入新的日期/时间,例如 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "未选择邮件",
@@ -514,7 +524,17 @@
"ai_verdict": "AI 判断",
"account": "账户",
"no_subject": "(无主题)"
}
},
"scheduled_banner": "计划于 {date} 发送",
"scheduled_send_created": "邮件已计划发送",
"cancel_scheduled_send": "取消发送",
"reschedule_send": "重新计划",
"cancel_and_edit": "取消并编辑",
"cancel_and_compose_again": "取消并重新撰写",
"reschedule_prompt": "输入新的日期/时间,例如 2026-05-04T15:30",
"scheduled_actions_only": "计划发送视图仅支持计划操作",
"undo_send_scheduled": "邮件已计划发送",
"undo_send": "撤销发送"
},
"email_composer": {
"new_message": "新邮件",
@@ -607,7 +627,19 @@
},
"add_link": "添加链接",
"link_url_prompt": "输入 URL",
"sending": "发送中..."
"sending": "发送中...",
"schedule_send": "计划发送",
"schedule_send_description": "选择服务器应何时发送此邮件。",
"schedule_send_required": "请选择日期和时间。",
"schedule_send_invalid": "请输入有效的日期和时间。",
"schedule_send_future": "请选择未来的日期和时间。",
"schedule_send_too_late": "该时间晚于服务器允许的范围。",
"schedule_send_unsupported": "此账户不支持计划发送。",
"schedule_send_cleanup_warning": "已创建计划发送,但清理草稿失败。",
"send_delay_unsupported": "此账户不支持发送延迟。",
"send_delay_unsupported_confirm": "此账户不支持发送延迟。是否立即发送?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "确认",
@@ -923,6 +955,8 @@
"reenable": "重新注册",
"relay_desc": "默认使用托管的 Bulwark 中继。仅当您自托管时才更改。",
"relay_label": "推送中继",
"relay_locked": "由管理员设置",
"relay_locked_desc": "推送中继 URL 已由您的管理员设置,无法更改。",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "在此设备上活动",
"status_busy": "工作中…",
@@ -978,6 +1012,7 @@
"label": "删除操作",
"description": "删除邮件后的行为",
"trash": "移至已删除",
"trash_and_read": "移至已删除并标记为已读",
"permanent": "永久删除",
"warning": "邮件将被永久删除且无法恢复。此操作是不可逆转的。"
},
@@ -1134,6 +1169,13 @@
"attachment_image_previews": {
"label": "在附件中显示图片预览",
"description": "将图片附件显示为缩略图卡片,而不是通用文件图标"
},
"send_delay": {
"label": "撤销发送 / 发送延迟",
"description": "通过服务器端短暂窗口延迟普通发送。",
"off": "关闭",
"seconds": "{seconds} 秒",
"unsupported": "当前账户未声明支持延迟发送。该设置仍会为其他账户保存。"
}
},
"composer": {
@@ -1803,7 +1845,11 @@
"color_tag": "标签",
"remove_color": "删除标签",
"items_selected": "已选择 {count} 封邮件",
"edit_draft": "编辑草稿"
"edit_draft": "编辑草稿",
"cancel_scheduled_send": "取消发送",
"reschedule_send": "重新计划",
"cancel_and_edit": "取消并编辑",
"cancel_and_compose_again": "取消并重新撰写"
},
"mailbox_context_menu": {
"mark_folder_read": "将文件夹标记为已读",
@@ -1881,6 +1927,8 @@
"expand_collapse": "展开/折叠会话"
},
"composer": {
"send": "发送邮件",
"schedule_send": "定时发送",
"template_picker": "打开模板选择器"
}
},
+2 -14
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-webmail",
"version": "1.7.1",
"version": "1.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-webmail",
"version": "1.7.1",
"version": "1.7.2",
"license": "AGPL-3.0-only",
"dependencies": {
"@tanstack/react-virtual": "^3.13.24",
@@ -5971,7 +5971,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -7643,17 +7642,6 @@
}
}
},
"node_modules/next-intl/node_modules/@swc/helpers": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-webmail",
"version": "1.7.1",
"version": "1.7.2",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only",
+347 -27
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { Email, Mailbox, StateChange } from "@/lib/jmap/types";
import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult } from "@/lib/jmap/types";
import type { UnifiedMailboxRole } from "@/lib/jmap/types";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useSettingsStore } from "@/stores/settings-store";
@@ -11,6 +11,17 @@ import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, adv
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
type ScheduledSubmissionMetadata = {
submissionId: string;
sendAt: string;
identityId: string;
undoStatus: 'pending' | 'final' | 'canceled';
};
const VIRTUAL_SCHEDULED_MAILBOX_ID = '__scheduled__';
type PendingUndoSend = { submissionId: string; emailId?: string; sendAt: string; isSmime: boolean };
interface EmailStore {
emails: Email[];
mailboxes: Mailbox[];
@@ -67,6 +78,17 @@ interface EmailStore {
unifiedErrors: Map<string, string>; // accountId -> error message
unifiedCounts: UnifiedMailboxCounts[];
// Scheduled send state
scheduledEmails: ScheduledEmail[];
scheduledEmailIds: Set<string>;
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>;
scheduledTotal: number;
scheduledHasMore: boolean;
scheduledNextPosition: number;
isLoadingScheduled: boolean;
isScheduledView: boolean;
pendingUndoSend: PendingUndoSend | null;
setEmails: (emails: Email[]) => void;
setMailboxes: (mailboxes: Mailbox[]) => void;
/** Cache or update the mailbox list for a specific account. */
@@ -111,8 +133,8 @@ interface EmailStore {
loadMoreEmails: (client: IJMAPClient) => Promise<void>;
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
fetchQuota: (client: IJMAPClient) => Promise<void>;
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], envelopeMailFrom?: string) => Promise<void>;
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>;
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string) => Promise<SendEmailResult>;
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]) => Promise<SendEmailResult>;
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
@@ -185,6 +207,16 @@ interface EmailStore {
refreshUnifiedCounts: (accounts: UnifiedAccountClient[]) => Promise<void>;
exitUnifiedView: () => void;
fetchScheduledEmails: (client: IJMAPClient) => Promise<void>;
loadMoreScheduledEmails: (client: IJMAPClient) => Promise<void>;
cancelScheduledEmail: (client: IJMAPClient, submissionId: string, emailId?: string) => Promise<void>;
cancelScheduledEmailForEdit: (client: IJMAPClient, email: ScheduledEmail | Email) => Promise<Email | null>;
rescheduleScheduledEmail: (client: IJMAPClient, submissionId: string, emailId: string, identityId: string, delayedUntil: string) => Promise<SendEmailResult>;
cancelUndoSend: (client: IJMAPClient, pending: PendingUndoSend) => Promise<Email | null>;
clearPendingUndoSend: () => void;
refreshScheduledMetadata: (client: IJMAPClient) => Promise<void>;
setScheduledView: (isScheduledView: boolean) => void;
// Mock data for demo
loadMockData: () => void;
}
@@ -219,6 +251,38 @@ function getNextSelectedEmail(state: { emails: Email[]; selectedEmail: Email | n
return getNextSelectedEmailAfterRemoval(state, new Set([removedEmailId]));
}
function annotateScheduledEmails(
emails: Email[],
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>
): Email[] {
if (scheduledSubmissionByEmailId.size === 0) return emails;
return emails.map(email => annotateScheduledEmail(email, scheduledSubmissionByEmailId));
}
function annotateScheduledEmail(
email: Email,
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>
): Email {
const scheduled = scheduledSubmissionByEmailId.get(email.id);
if (!scheduled) return email;
return {
...email,
scheduledSendAt: scheduled.sendAt,
emailSubmissionId: scheduled.submissionId,
scheduledIdentityId: scheduled.identityId,
scheduledUndoStatus: scheduled.undoStatus,
isScheduled: true,
};
}
function shouldClearPendingUndoSend(pending: PendingUndoSend | null, scheduledEmails: ScheduledEmail[]): boolean {
if (!pending) return false;
const pendingSendTime = new Date(pending.sendAt).getTime();
if (Number.isFinite(pendingSendTime) && pendingSendTime <= Date.now()) return true;
const scheduledEmail = scheduledEmails.find(email => email.emailSubmissionId === pending.submissionId);
return scheduledEmail?.scheduledUndoStatus !== undefined && scheduledEmail.scheduledUndoStatus !== 'pending';
}
/**
* When the mail view is showing a non-active account (Pro shell's
* Thunderbird-style sidebar), redirect read/write operations to that
@@ -244,12 +308,6 @@ function resolveActionMailboxes(): Mailbox[] {
return state.mailboxes;
}
/**
* After a mailbox-list mutation (create/rename/delete/etc.), refresh the
* cache for whichever account we're operating on. Writes the result to the
* standard `mailboxes` slot for the active account, or the per-account
* cache for non-active accounts so the Pro sidebar stays in sync.
*/
/**
* Builds the `UnifiedAccountClient[]` list used by every unified fan-out
* action (browse, load-more, search). Each entry has a JMAP client plus a
@@ -305,6 +363,12 @@ export async function buildUnifiedAccountClients(
return built;
}
/**
* After a mailbox-list mutation (create/rename/delete/etc.), refresh the
* cache for whichever account we're operating on. Writes the result to the
* standard `mailboxes` slot for the active account, or the per-account
* cache for non-active accounts so the Pro sidebar stays in sync.
*/
async function refreshMailboxesForViewingAccount(fallbackClient: IJMAPClient): Promise<void> {
const viewingId = useEmailStore.getState().viewingAccountId;
const client = resolveActionClient(fallbackClient);
@@ -387,6 +451,17 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
unifiedErrors: new Map(),
unifiedCounts: [],
// Scheduled send state
scheduledEmails: [],
scheduledEmailIds: new Set(),
scheduledSubmissionByEmailId: new Map(),
scheduledTotal: 0,
scheduledHasMore: false,
scheduledNextPosition: 0,
isLoadingScheduled: false,
isScheduledView: false,
pendingUndoSend: null,
// Spam undo cache
spamUndoCache: new Map(),
@@ -516,7 +591,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Auto-select inbox if no mailbox is selected or the current selection
// doesn't exist in the fetched list (e.g. after an account switch)
const currentSelectedMailbox = get().selectedMailbox;
const selectionValid = currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox);
const selectionValid = currentSelectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID
|| (currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox));
const loadingPatch = isInitialLoad ? { isLoading: false } : {};
if (!selectionValid) {
// Find inbox from PRIMARY account (not shared accounts)
@@ -568,6 +644,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ isLoading: true, error: null }); // Keep previous emails visible during transition
try {
const targetMailboxId = mailboxId || get().selectedMailbox;
if (targetMailboxId === VIRTUAL_SCHEDULED_MAILBOX_ID) {
set({ isLoading: false, emails: [], hasMoreEmails: false, totalEmails: 0 });
await get().fetchScheduledEmails(client);
return;
}
const effectiveClient = resolveActionClient(client);
// Find the mailbox to get its accountId (for shared folder support)
@@ -589,7 +670,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// all folders that carry the tag are returned.
const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter);
set({
emails: result.emails,
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false
@@ -657,6 +738,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ isLoadingMore: true, error: null });
try {
if (selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID) {
set({ isLoadingMore: false });
await get().loadMoreScheduledEmails(client);
return;
}
const effectiveClient = resolveActionClient(client);
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
@@ -702,7 +789,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Deduplicate: the server may return overlapping results if new emails
// arrived between paginated requests and shifted positions.
const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = result.emails.filter((e: Email) => !existingIds.has(e.id));
const newEmails = annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId).filter((e: Email) => !existingIds.has(e.id));
set({
emails: [...currentEmails, ...newEmails],
@@ -732,7 +819,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const email = await resolveActionClient(client).getEmail(emailId, accountId);
if (email) {
set({ selectedEmail: email });
const annotatedEmail = annotateScheduledEmail(email, get().scheduledSubmissionByEmailId);
set({ selectedEmail: annotatedEmail });
return annotatedEmail;
}
return email;
} catch (error) {
@@ -752,12 +841,18 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, envelopeMailFrom) => {
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom) => {
set({ isLoading: true, error: null });
try {
await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, envelopeMailFrom);
const result = await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom);
// Refresh handled by UI layer for immediate feedback
set({ isLoading: false });
set({
isLoading: false,
pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt
? { submissionId: result.emailSubmissionId, emailId: result.emailId, sendAt: result.sendAt, isSmime: false }
: get().pendingUndoSend,
});
return result;
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to send email",
@@ -767,15 +862,21 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
sendRawEmail: async (client, rawMimeBlob, identityId) => {
sendRawEmail: async (client, rawMimeBlob, identityId, delayedUntil, envelopeRecipients) => {
set({ isLoading: true, error: null });
try {
const mailboxes = await client.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
if (!sentMailbox) throw new Error('No sent mailbox found');
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id);
set({ isLoading: false });
const result = await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id, delayedUntil, envelopeRecipients);
set({
isLoading: false,
pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt
? { submissionId: result.emailSubmissionId, emailId: result.emailId, sendAt: result.sendAt, isSmime: true }
: get().pendingUndoSend,
});
return result;
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to send email",
@@ -1325,7 +1426,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
set({
emails: result.emails,
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
@@ -1400,7 +1501,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
set({
emails: result.emails,
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
@@ -1930,6 +2031,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
get().fetchTagCounts(client);
}
if (accountChanges.EmailSubmission) {
await get().refreshScheduledMetadata(client);
if (get().isScheduledView) {
await get().fetchScheduledEmails(client);
}
}
// Handle Mailbox state changes - refresh mailbox list
if (accountChanges.Mailbox) {
await get().fetchMailboxes(client);
@@ -1977,6 +2085,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Only refresh if a mailbox is currently selected
if (!selectedMailbox) return;
if (selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID) {
await get().fetchScheduledEmails(client);
return;
}
try {
// Fetch emails for the current mailbox without clearing the list first
// This provides a smoother update experience
@@ -2003,6 +2116,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
const currentEmails = get().emails;
const previousTotal = get().totalEmails;
// Only notify for genuinely new incoming mail in the Inbox.
// Without these guards the toast/sound also fires when sending,
@@ -2020,15 +2134,19 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Merge the refreshed first page with the existing loaded emails.
// This avoids discarding already-loaded pages which would cause the
// virtual list to shrink and then rapidly re-load (scroll bounce).
const freshMap = new Map(result.emails.map((e: Email) => [e.id, e]));
const refreshedEmails = annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId);
// Build the merged list: start with the fresh first page, then append
// existing emails beyond that page (if any), skipping duplicates and
// emails removed from the first page (e.g. deleted or moved).
const merged: Email[] = [...result.emails];
const mergedIds = new Set(result.emails.map((e: Email) => e.id));
// existing emails beyond that page (if any), skipping duplicates. Do not
// append the whole previous list: drafts are saved as destroy+create, so
// the old draft can disappear from the refreshed first page and must not
// be reintroduced from stale local state.
const merged: Email[] = [...refreshedEmails];
const mergedIds = new Set(refreshedEmails.map((e: Email) => e.id));
const insertedCount = Math.max((result.total || 0) - previousTotal, 0);
const appendFromIndex = Math.max(refreshedEmails.length - insertedCount, 0);
for (const email of currentEmails) {
for (const email of currentEmails.slice(appendFromIndex)) {
if (!mergedIds.has(email.id)) {
merged.push(email);
mergedIds.add(email.id);
@@ -2384,6 +2502,208 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
});
},
setScheduledView: (isScheduledView) => set(state => {
const leavingScheduled = !isScheduledView && state.selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID;
return {
isScheduledView,
selectedMailbox: isScheduledView ? VIRTUAL_SCHEDULED_MAILBOX_ID : leavingScheduled ? "" : state.selectedMailbox,
selectedEmail: leavingScheduled ? null : state.selectedEmail,
selectedEmailIds: leavingScheduled ? new Set<string>() : state.selectedEmailIds,
};
}),
clearPendingUndoSend: () => set({ pendingUndoSend: null }),
fetchScheduledEmails: async (client) => {
set({ isLoadingScheduled: true, error: null });
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.getScheduledEmails(emailsPerPage, 0);
const scheduledEmailIds = new Set(result.emails.map(email => email.id));
const scheduledSubmissionByEmailId = new Map(result.emails.map(email => [email.id, {
submissionId: email.emailSubmissionId,
sendAt: email.scheduledSendAt,
identityId: email.scheduledIdentityId,
undoStatus: email.scheduledUndoStatus,
}]));
const pendingUndoSend = get().pendingUndoSend;
set({
scheduledEmails: result.emails,
scheduledEmailIds,
scheduledSubmissionByEmailId,
scheduledTotal: result.total,
scheduledHasMore: result.hasMore,
scheduledNextPosition: result.nextPosition,
isLoadingScheduled: false,
pendingUndoSend: shouldClearPendingUndoSend(pendingUndoSend, result.emails) ? null : pendingUndoSend,
});
} catch (error) {
console.error('Failed to fetch scheduled emails:', error);
set({
error: error instanceof Error ? error.message : 'Failed to fetch scheduled emails',
scheduledEmails: [],
scheduledEmailIds: new Set(),
scheduledSubmissionByEmailId: new Map(),
scheduledTotal: 0,
scheduledHasMore: false,
scheduledNextPosition: 0,
isLoadingScheduled: false,
});
}
},
loadMoreScheduledEmails: async (client) => {
const { isLoadingScheduled, scheduledHasMore, scheduledEmails, scheduledNextPosition } = get();
if (isLoadingScheduled || !scheduledHasMore) return;
set({ isLoadingScheduled: true, error: null });
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.getScheduledEmails(emailsPerPage, scheduledNextPosition);
const merged = [...scheduledEmails, ...result.emails.filter(email => !scheduledEmails.some(existing => existing.id === email.id))];
const pendingUndoSend = get().pendingUndoSend;
set({
scheduledEmails: merged,
scheduledEmailIds: new Set(merged.map(email => email.id)),
scheduledSubmissionByEmailId: new Map(merged.map(email => [email.id, {
submissionId: email.emailSubmissionId,
sendAt: email.scheduledSendAt,
identityId: email.scheduledIdentityId,
undoStatus: email.scheduledUndoStatus,
}])),
scheduledTotal: result.total,
scheduledHasMore: result.hasMore,
scheduledNextPosition: result.nextPosition,
isLoadingScheduled: false,
pendingUndoSend: shouldClearPendingUndoSend(pendingUndoSend, merged) ? null : pendingUndoSend,
});
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to load scheduled emails', isLoadingScheduled: false });
}
},
refreshScheduledMetadata: async (client) => {
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const allEmails: ScheduledEmail[] = [];
let position = 0;
let hasMore = true;
let total = 0;
while (hasMore) {
const page = await client.getScheduledEmails(emailsPerPage, position);
allEmails.push(...page.emails.filter(email => !allEmails.some(existing => existing.id === email.id)));
total = page.total;
hasMore = page.hasMore && page.nextPosition > position;
position = page.nextPosition;
}
const pendingUndoSend = get().pendingUndoSend;
set({
scheduledEmails: get().isScheduledView ? allEmails : get().scheduledEmails,
scheduledEmailIds: new Set(allEmails.map(email => email.id)),
scheduledSubmissionByEmailId: new Map(allEmails.map(email => [email.id, {
submissionId: email.emailSubmissionId,
sendAt: email.scheduledSendAt,
identityId: email.scheduledIdentityId,
undoStatus: email.scheduledUndoStatus,
}])),
scheduledTotal: total,
scheduledHasMore: false,
scheduledNextPosition: position,
pendingUndoSend: shouldClearPendingUndoSend(pendingUndoSend, allEmails) ? null : pendingUndoSend,
});
} catch (error) {
console.error('Failed to refresh scheduled metadata:', error);
}
},
cancelScheduledEmail: async (client, submissionId, emailId) => {
await client.cancelEmailSubmission(submissionId);
if (emailId) {
await client.deleteEmail(emailId);
set(state => ({
selectedEmail: state.selectedEmail?.id === emailId ? null : state.selectedEmail,
selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== emailId)),
}));
}
if (get().pendingUndoSend?.submissionId === submissionId) {
set({ pendingUndoSend: null });
}
await get().fetchScheduledEmails(client);
},
cancelScheduledEmailForEdit: async (client, email) => {
const submissionId = email.emailSubmissionId;
if (!submissionId) return null;
await client.cancelEmailSubmission(submissionId);
if (get().pendingUndoSend?.submissionId === submissionId) {
set({ pendingUndoSend: null });
}
if (email.isSmimeScheduled) {
await client.deleteEmail(email.id);
set(state => ({
selectedEmail: state.selectedEmail?.id === email.id ? null : state.selectedEmail,
selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== email.id)),
}));
await get().fetchScheduledEmails(client);
return null;
}
const mailboxes = get().mailboxes.length > 0 ? get().mailboxes : await client.getMailboxes();
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
if (draftsMailbox) {
await client.restoreEmailToDraft(email.id, draftsMailbox.originalId || draftsMailbox.id, sentMailbox?.originalId || sentMailbox?.id);
}
await get().fetchScheduledEmails(client);
const restored = await client.getEmail(email.id);
return restored;
},
rescheduleScheduledEmail: async (client, submissionId, emailId, identityId, delayedUntil) => {
let result: SendEmailResult | undefined;
try {
result = await client.rescheduleEmailSubmission(submissionId, emailId, identityId, delayedUntil);
const pendingUndoSend = get().pendingUndoSend;
if (pendingUndoSend?.submissionId === submissionId) {
set({ pendingUndoSend: { ...pendingUndoSend, submissionId: result.emailSubmissionId || submissionId, sendAt: result.sendAt || delayedUntil } });
}
return result;
} finally {
await get().fetchScheduledEmails(client);
if (result && get().selectedEmail?.id === emailId) {
const refreshed = get().scheduledEmails.find(email => email.id === emailId);
set(state => ({
selectedEmail: refreshed || (state.selectedEmail ? {
...state.selectedEmail,
emailSubmissionId: result?.emailSubmissionId || submissionId,
scheduledSendAt: result?.sendAt || delayedUntil,
scheduledIdentityId: identityId,
scheduledUndoStatus: 'pending' as const,
isScheduled: true,
} : state.selectedEmail),
}));
}
}
},
cancelUndoSend: async (client, pending) => {
await client.cancelEmailSubmission(pending.submissionId);
if (pending.emailId && pending.isSmime) {
await client.deleteEmail(pending.emailId);
set(state => ({
selectedEmail: state.selectedEmail?.id === pending.emailId ? null : state.selectedEmail,
selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== pending.emailId)),
}));
} else if (pending.emailId) {
const mailboxes = get().mailboxes.length > 0 ? get().mailboxes : await client.getMailboxes();
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
if (draftsMailbox) {
await client.restoreEmailToDraft(pending.emailId, draftsMailbox.originalId || draftsMailbox.id, sentMailbox?.originalId || sentMailbox?.id);
}
}
await get().refreshScheduledMetadata(client);
set({ pendingUndoSend: null });
return pending.emailId && !pending.isSmime ? client.getEmail(pending.emailId) : null;
},
loadMockData: () => {
const mockEmails: Email[] = [
{
+11
View File
@@ -41,6 +41,7 @@ export type ToolbarPosition = 'top' | 'below-subject';
export type ArchiveMode = 'single' | 'year' | 'month';
export type MailLayout = 'split' | 'focus' | 'horizontal';
export type CalendarHoverPreview = 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s';
export type SendDelaySeconds = 0 | 10 | 30 | 60;
export type ProtocolOpenMode = 'active-session' | 'new-tab';
/**
@@ -153,6 +154,7 @@ interface SettingsState {
autoSelectReplyIdentity: boolean;
plainTextMode: boolean; // Send plain text only (no rich text editor)
subAddressDelimiter: string; // Character separating user from tag (e.g. "user+tag@")
sendDelaySeconds: SendDelaySeconds;
signaturePosition: SignaturePosition; // Position of the signature relative to quoted text in replies/forwards
signatureSeparatorEnabled: boolean; // Prefix the signature with the RFC 3676 "-- " delimiter
@@ -327,6 +329,7 @@ const DEFAULT_SETTINGS = {
autoSelectReplyIdentity: false,
plainTextMode: false,
subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER,
sendDelaySeconds: 0 as SendDelaySeconds,
signaturePosition: 'below_quote' as SignaturePosition,
signatureSeparatorEnabled: true,
@@ -520,6 +523,7 @@ export const useSettingsStore = create<SettingsState>()(
autoSelectReplyIdentity: state.autoSelectReplyIdentity,
plainTextMode: state.plainTextMode,
subAddressDelimiter: state.subAddressDelimiter,
sendDelaySeconds: state.sendDelaySeconds,
signaturePosition: state.signaturePosition,
signatureSeparatorEnabled: state.signatureSeparatorEnabled,
sessionTimeout: state.sessionTimeout,
@@ -589,6 +593,10 @@ export const useSettingsStore = create<SettingsState>()(
if (key === 'subAddressDelimiter' && !isValidSubAddressDelimiter(settings[key])) {
return;
}
if (key === 'sendDelaySeconds' && ![0, 10, 30, 60].includes(settings[key])) {
set({ sendDelaySeconds: 0 });
return;
}
if (DEVICE_LOCAL_SETTING_KEYS.has(key)) {
return;
}
@@ -767,6 +775,9 @@ export const useSettingsStore = create<SettingsState>()(
state.density = state.listDensity;
delete state.listDensity;
}
if (![0, 10, 30, 60].includes(state.sendDelaySeconds as number)) {
state.sendDelaySeconds = 0;
}
if (version < 3 && typeof state.protocolOpenMode !== 'string' && typeof state.protocolMailtoOpenMode === 'string') {
state.protocolOpenMode = state.protocolMailtoOpenMode;
}