Compare commits

..
27 Commits
Author SHA1 Message Date
Linus Rath 6a8ad525f1 chore: update version to 1.5.4 2026-05-01 02:17:33 +02:00
Linus Rath 31d17098d6 feat: open mail from push notification clicks 2026-05-01 02:13:25 +02:00
Linus Rath 3f97e6ed8d fix: scope email notifications to genuine inbox deliveries 2026-05-01 02:08:13 +02:00
Linus Rath 2dea33e698 feat: improve new email push notification logic for inbox 2026-05-01 01:57:57 +02:00
Linus Rath 123764f8b8 feat: improve new email notification logic for inbox 2026-05-01 01:55:24 +02:00
Linus Rath ec0f355c13 feat: allow custom sub-addressing delimiter character #239 2026-05-01 01:48:39 +02:00
Linus Rath c555973b6b feat: configurable sub-addressing delimiter #239 2026-05-01 01:42:06 +02:00
Linus Rath a8db02e881 i18n: add missing keys across 15 locales 2026-05-01 01:26:22 +02:00
AbdullahandLinus Rath 7dc5984359 feat(i18n): add Turkish localization 2026-05-01 01:16:30 +02:00
Linus Rath 1c3003421e fix: extend timeout for PushVerification and clean up leftover subscriptions 2026-05-01 00:54:40 +02:00
Linus Rath f3d9115ecd feat: web push notifications for PWA #233 2026-05-01 00:26:48 +02:00
Linus Rath 4400a7abba fix: evict unrecoverable basic-auth accounts on reload 2026-04-30 15:39:11 +02:00
Linus Rath 45a4db1c22 fix: pin JMAP auth verification to configured server URL #237 2026-04-30 15:34:14 +02:00
Linus Rath 65eef4b2b8 fix: persist htmlBody in drafts to preserve rich formatting #236 2026-04-30 15:24:09 +02:00
Linus Rath 25de7d996c feat: add tables to composer #236 2026-04-30 15:16:51 +02:00
Luis Felipe MarzagaoandLinus Rath c406fbb73e fixup! fix: implement useTranslations for start date on event detail popover 2026-04-30 11:08:16 +02:00
Luis Felipe MarzagaoandLinus Rath 31024396e3 fixup! fix: implement useTranslations for start date on event modal 2026-04-30 11:08:16 +02:00
Luis Felipe MarzagaoandLinus Rath f0967f90eb fix: implement useTranslations for start date on event modal 2026-04-30 11:08:16 +02:00
Luis Felipe MarzagaoandLinus Rath a4bb8e0c28 fix: implement useTranslations for start date on event detail popover 2026-04-30 11:08:16 +02:00
Linus Rath 7188abc9bc fix: set In-Reply-To and References on replies #234 2026-04-30 01:25:04 +02:00
Linus Rath 4a91cd0c44 style: drop iframe border-radius 2026-04-29 20:26:40 +02:00
Linus Rath 6abf8a5dd8 fix: detect <style> tag for padding 2026-04-29 20:25:24 +02:00
Linus Rath 3667c842c6 fix: light-mode override to body content only 2026-04-29 20:17:59 +02:00
Linus Rath b64721b43c fix: pad bare HTML emails like plain-text mails 2026-04-29 20:09:00 +02:00
Linus Rath 6b5ca2cb89 fix: smooth out body load, prevent flicker 2026-04-29 20:03:58 +02:00
Linus Rath 0f6e4f995f fix: prevent iframe flash on Load images or Trust sender 2026-04-29 18:29:48 +02:00
Linus Rath 0b6fdcabfb fix: update connect-src to include http protocol in development mode 2026-04-29 13:55:38 +02:00
59 changed files with 5929 additions and 306 deletions
+27
View File
@@ -1,5 +1,32 @@
# Changelog
## 1.5.4 (2026-05-01)
### Features
- **PWA**: Web push notifications for new inbox mail (#233), with click-through to open the message
- **Composer**: Insert and edit tables in rich-text emails (#236)
- **Mail**: Configurable sub-addressing delimiter character (#239)
- **i18n**: Turkish localization
- **i18n**: Missing keys filled in across 15 locales
### Fixes
- **Mail**: Set In-Reply-To and References headers on replies (#234)
- **Mail**: Persist htmlBody in drafts to preserve rich formatting (#236)
- **Auth**: Pin JMAP auth verification to the configured server URL (#237)
- **Auth**: Evict unrecoverable basic-auth accounts on reload
- **Notifications**: Scope new-mail notifications to genuine inbox deliveries
- **Notifications**: Extend PushVerification timeout and clean up leftover subscriptions
- **Viewer**: Smooth out body load to prevent flicker on first render
- **Viewer**: Prevent iframe flash when loading images or trusting the sender
- **Viewer**: Pad bare HTML emails like plain-text mails for consistent layout
- **Viewer**: Light-mode override now only affects body content
- **Viewer**: Detect `<style>` tag when applying padding
- **Viewer**: Drop iframe border-radius
- **Calendar**: Localize event start date in detail popover and event modal
- **Dev**: Include http protocol in connect-src for development mode CSP
## 1.5.3 (2026-04-28)
> **New:** Help shape Bulwark Webmail. Each instance now sends a lightweight daily heartbeat (version, platform, bucketed account counts, feature toggles - never message data or PII) so we can see which platforms and features actually get used and prioritize fixes where they matter most. You're in control: opt out any time from **Admin → Telemetry** or by setting `BULWARK_TELEMETRY=off`. Full schema in the [privacy notice](https://bulwarkmail.org/docs/legal/privacy/telemetry).
+1 -1
View File
@@ -94,7 +94,7 @@
## Internationalization
14 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · 한국어 · Polski · Latviešu · 简体中文 · Українська
15 languages: English · Français · 日本語 · Español · Italiano · Deutsch · Nederlands · Português · Русский · Türkçe · 한국어 · Polski · Latviešu · 简体中文 · Українська
Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`.
+1 -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.5.3-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.5.4-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)
</div>
+1 -1
View File
@@ -1 +1 @@
1.5.3
1.5.4
+66 -6
View File
@@ -51,6 +51,7 @@ import { Input } from "@/components/ui/input";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { isFilePreviewable } from "@/lib/file-preview";
import { appendPlainTextSignature } from "@/lib/signature-utils";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button";
@@ -651,11 +652,50 @@ export default function Home() {
});
}, [enableUnifiedMailbox, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
// System-notification click handler. The push SW navigates the user back
// here with `?email=<id>` (specific email it built the toast from) or
// `?openLatestUnread=1` (generic "New mail" toast — happens when the
// preview API failed). We resolve those params once after the inbox has
// finished loading and open the right message, then strip the params so a
// refresh doesn't re-open it.
const notificationParamHandledRef = useRef(false);
useEffect(() => {
if (notificationParamHandledRef.current) return;
if (!isAuthenticated || !client) return;
if (mailboxes.length === 0) return;
const params = new URLSearchParams(window.location.search);
const emailIdParam = params.get('email');
const openLatestUnread = params.get('openLatestUnread') === '1';
if (!emailIdParam && !openLatestUnread) return;
// For the latest-unread case we need the inbox emails loaded; bail and
// let the effect re-run once `emails` is populated.
if (openLatestUnread && emails.length === 0) return;
notificationParamHandledRef.current = true;
window.history.replaceState({}, '', window.location.pathname);
if (emailIdParam) {
setLoadingEmail(true);
fetchEmailContent(client, emailIdParam).finally(() => setLoadingEmail(false));
return;
}
// emails are sorted receivedAt-desc, so the first unread is the newest.
const newestUnread = emails.find(e => !e.keywords?.$seen);
if (newestUnread) {
selectEmail(newestUnread);
}
}, [isAuthenticated, client, mailboxes.length, emails, fetchEmailContent, selectEmail, setLoadingEmail]);
// Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive)
useEffect(() => {
if (!selectedEmail || !client) return;
// If the email lacks bodyValues, it was auto-selected from the list and needs full content
if (!selectedEmail.bodyValues) {
// If the email lacks bodyValues, it was auto-selected from the list and needs full content.
// Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before
// calling selectEmail on the stub), to avoid a duplicate request.
if (!selectedEmail.bodyValues && !isLoadingEmail) {
const perAccountClient = isUnifiedView && selectedEmail.accountId
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
: undefined;
@@ -750,6 +790,8 @@ export default function Home() {
fromName?: string;
identityId?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[];
references?: string[];
}) => {
if (!client) return;
@@ -757,7 +799,7 @@ 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);
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);
setShowComposer(false);
// Mark the original email with $answered or $forwarded keyword
@@ -1449,6 +1491,12 @@ export default function Home() {
const originalEmailId = selectedEmail.id;
// RFC 5322 §3.6.4 threading — keep the conversation stitched together (#234).
const threading = computeReplyThreadingHeaders({
messageId: selectedEmail.messageId,
references: selectedEmail.references,
});
// Send reply with just the body text
await sendEmail(
client,
@@ -1460,7 +1508,11 @@ export default function Home() {
primaryIdentity?.id,
primaryIdentity?.email,
undefined,
primaryIdentity?.name || undefined
primaryIdentity?.name || undefined,
undefined,
undefined,
threading?.inReplyTo,
threading?.references,
);
// Mark the original email as answered
@@ -1502,7 +1554,13 @@ export default function Home() {
setShowComposer(false);
}
// Set loading state immediately (keep current email visible)
// 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);
if (listEmail) {
selectEmail(listEmail);
}
setLoadingEmail(true);
// On mobile, switch to viewer
@@ -1519,7 +1577,6 @@ export default function Home() {
try {
// In unified view each email carries its own accountId. Use that
// account's client so we fetch from the server that actually owns it.
const listEmail = emails.find(e => e.id === email.id);
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
const perAccountClient = emailAccountId
? useAuthStore.getState().getClientForAccount(emailAccountId)
@@ -2111,6 +2168,9 @@ export default function Home() {
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
receivedAt: selectedEmail.receivedAt,
attachments: selectedEmail.attachments,
messageId: selectedEmail.messageId,
inReplyTo: selectedEmail.inReplyTo,
references: selectedEmail.references,
} : undefined)}
initialDraftText={composerDraftText}
initialData={pendingDraft}
+29 -1
View File
@@ -10,6 +10,7 @@ import {
setStalwartAuthContextInStore,
} from '@/lib/stalwart/auth-context';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
const COOKIE_OPTIONS = {
@@ -38,10 +39,37 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Pin the upstream URL to the configured JMAP server so an unauthenticated
// caller cannot point this route at internal hosts. Only when no server URL
// is configured AND the deployment explicitly allows custom JMAP endpoints
// do we honor the body URL — and even then it must be a public URL.
await configManager.ensureLoaded();
const configuredServerUrl =
configManager.get<string>('jmapServerUrl', '') ||
process.env.JMAP_SERVER_URL ||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
'';
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
let upstreamUrl: string;
let upstreamTrusted: boolean;
if (configuredServerUrl) {
upstreamUrl = configuredServerUrl;
upstreamTrusted = true;
} else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) {
return NextResponse.json({ error: 'Server URL is not allowed' }, { status: 400 });
}
upstreamUrl = serverUrl;
upstreamTrusted = false;
} else {
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
}
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
const cookieName = sessionCookieName(slot);
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
const token = encryptSession(normalizedServerUrl, username, password);
const cookieStore = await cookies();
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
+30 -1
View File
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
function getSlot(request: NextRequest, bodySlot: unknown): number {
@@ -24,8 +26,35 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Pin the upstream URL to the configured JMAP server so an unauthenticated
// caller cannot point this route at internal hosts. Only when no server URL
// is configured AND the deployment explicitly allows custom JMAP endpoints
// do we honor the body URL — and even then it must be a public URL.
await configManager.ensureLoaded();
const configuredServerUrl =
configManager.get<string>('jmapServerUrl', '') ||
process.env.JMAP_SERVER_URL ||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
'';
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
let upstreamUrl: string;
let upstreamTrusted: boolean;
if (configuredServerUrl) {
upstreamUrl = configuredServerUrl;
upstreamTrusted = true;
} else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) {
return NextResponse.json({ error: 'Server URL is not allowed' }, { status: 400 });
}
upstreamUrl = serverUrl;
upstreamTrusted = false;
} else {
return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 });
}
const slot = getSlot(request, bodySlot);
const normalizedServerUrl = await verifyJmapAuth(serverUrl, authHeader);
const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted });
await setStalwartAuthContext(slot, {
serverUrl: normalizedServerUrl,
+140
View File
@@ -0,0 +1,140 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* GET /api/push/preview
*
* Called from the service worker when a Web Push wake-up arrives. Fetches the
* latest unread email so the SW can build an enriched system notification
* (sender, subject, avatar) without ever exposing JMAP credentials to the
* SW context.
*
* The relay's push payload is intentionally minimal (just a state-change
* ping), so this is what makes "From: Alice / Subject: …" appear instead of
* a generic "New mail" string.
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const sessionRes = await fetch(`${creds.serverUrl}/.well-known/jmap`, {
headers: { Authorization: creds.authHeader },
});
if (!sessionRes.ok) {
return NextResponse.json({ error: 'JMAP session failed' }, { status: 502 });
}
const session = (await sessionRes.json()) as {
apiUrl?: string;
primaryAccounts?: Record<string, string>;
};
const apiUrl = session.apiUrl;
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
if (!apiUrl || !accountId) {
return NextResponse.json({ error: 'Incomplete JMAP session' }, { status: 502 });
}
// Find the inbox, then pull the most recent unread message in it. We use
// a single batched JMAP request with back-references so this round-trip
// is one POST regardless of how many messages exist.
const requestBody = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
[
'Mailbox/query',
{ accountId, filter: { role: 'inbox' }, limit: 1 },
'mb',
],
[
'Email/query',
{
accountId,
filter: {
operator: 'AND',
conditions: [
{ inMailbox: { resultOf: 'mb', name: 'Mailbox/query', path: '/ids/0' } },
{ notKeyword: '$seen' },
],
},
sort: [{ property: 'receivedAt', isAscending: false }],
limit: 1,
calculateTotal: true,
},
'eq',
],
[
'Email/get',
{
accountId,
'#ids': { resultOf: 'eq', name: 'Email/query', path: '/ids' },
properties: ['id', 'threadId', 'from', 'subject', 'preview', 'receivedAt'],
},
'eg',
],
],
};
const jmapRes = await fetch(apiUrl, {
method: 'POST',
headers: {
Authorization: creds.authHeader,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!jmapRes.ok) {
return NextResponse.json({ error: 'JMAP request failed' }, { status: 502 });
}
const data = (await jmapRes.json()) as {
methodResponses: [string, Record<string, unknown>, string][];
};
type EmailLite = {
id: string;
threadId: string;
from?: { name?: string | null; email?: string }[] | null;
subject?: string | null;
preview?: string | null;
receivedAt?: string | null;
};
let email: EmailLite | null = null;
let unreadTotal = 0;
for (const [method, body] of data.methodResponses) {
if (method === 'Email/query') {
unreadTotal = ((body as { total?: number }).total) ?? 0;
}
if (method === 'Email/get') {
const list = (body as { list?: EmailLite[] }).list ?? [];
email = list[0] ?? null;
}
}
return NextResponse.json({
email,
unreadTotal,
}, {
headers: {
// SW already gates on its own logic - don't let push events get
// cached and served stale.
'Cache-Control': 'no-store',
},
});
} catch (error) {
// `fetch failed` from undici is too generic to debug — the real reason
// (ENOTFOUND, ECONNREFUSED, TLS error, …) is on `error.cause`.
const err = error as Error & { cause?: { code?: string; message?: string } };
logger.error('push preview failed', {
error: err?.message ?? 'Unknown error',
causeCode: err?.cause?.code,
causeMessage: err?.cause?.message,
});
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
}
+76
View File
@@ -204,6 +204,31 @@ body {
min-width: 100%;
}
/* Forces light-theme CSS variables inside the email content area, so when
"Always Show Emails in Light Mode" is enabled in dark theme the surrounding
sender info / attachments / plain-text body don't end up with light text
on a white background. */
.email-content-light {
--color-background: #ffffff;
--color-foreground: #0f172a;
--color-muted: #f1f5f9;
--color-muted-foreground: #64748b;
--color-border: #e2e8f0;
--color-card: #ffffff;
--color-card-foreground: #0f172a;
--color-popover: #ffffff;
--color-popover-foreground: #0f172a;
--color-secondary: #f8fafc;
--color-secondary-foreground: #0f172a;
--color-accent: #dbeafe;
--color-accent-foreground: #1e40af;
--color-input: #e2e8f0;
}
.email-content-light .email-content-text a {
color: #2563eb;
}
.email-content-text {
padding: 1rem 1.25rem;
}
@@ -680,3 +705,54 @@ body {
.tiptap .ProseMirror-selectednode img {
outline: none;
}
.tiptap table {
border-collapse: collapse;
margin: 0.5rem 0;
table-layout: fixed;
width: 100%;
overflow: hidden;
}
.tiptap table td,
.tiptap table th {
border: 1px solid var(--color-border);
padding: 0.375rem 0.5rem;
vertical-align: top;
position: relative;
min-width: 1em;
}
.tiptap table th {
background-color: var(--color-muted) !important;
color: var(--color-foreground) !important;
font-weight: 600;
text-align: left;
}
.tiptap table p {
margin: 0;
}
.tiptap table .selectedCell::after {
background: rgba(99, 102, 241, 0.15);
content: "";
inset: 0;
pointer-events: none;
position: absolute;
z-index: 2;
}
.tiptap table .column-resize-handle {
background-color: var(--color-primary);
bottom: -2px;
pointer-events: none;
position: absolute;
right: -2px;
top: 0;
width: 4px;
}
.tiptap.resize-cursor {
cursor: col-resize;
}
+4 -1
View File
@@ -19,6 +19,7 @@ import {
getUserStatus,
getParticipantList,
} from "@/lib/calendar-participants";
import { useFormatEventDate } from "@/hooks/use-format-event-date";
interface EventDetailPopoverProps {
event: CalendarEvent;
@@ -253,6 +254,8 @@ export function EventDetailPopover({
const hasParticipants = participants.length > 0;
const formatEventDate = useFormatEventDate();
const popover = (
<div
ref={popoverRef}
@@ -329,7 +332,7 @@ export function EventDetailPopover({
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="text-sm">
<span className="font-medium text-foreground">
{format(startDate, "EEE, MMM d, yyyy")}
{formatEventDate(startDate)}
</span>
{event.showWithoutTime ? (
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
+108 -106
View File
@@ -21,6 +21,7 @@ import {
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store";
import { generateUUID } from "@/lib/utils";
import { useFormatEventDate } from "@/hooks/use-format-event-date";
export interface PendingEventPreview {
start: Date;
@@ -130,6 +131,7 @@ export function EventModal({
const timeFormat = useSettingsStore((s) => s.timeFormat);
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
const isEdit = !!event;
const formatEventDate = useFormatEventDate();
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit");
const userIsOrganizer = useMemo(() => {
@@ -495,14 +497,14 @@ export function EventModal({
return (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto">
<div className="px-6 py-4 space-y-3">
<div className="flex items-start gap-3 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/50 px-4 py-3">
<CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
@@ -517,7 +519,7 @@ export function EventModal({
</div>
<div className="text-sm">
<span className="font-medium">{format(startD, "EEE, MMM d, yyyy")}</span>
<span className="font-medium">{formatEventDate(startD)}</span>
{!event.showWithoutTime && (
<span className="text-muted-foreground ml-2">
{format(startD, timeDisplayFmt)} {format(endD, timeDisplayFmt)}
@@ -550,48 +552,48 @@ export function EventModal({
</div>
)}
</div>
</div>
</div>
<div className="px-6 py-4 border-t border-border flex-shrink-0">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
<div className="flex gap-2">
<Button
size="sm"
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
onClick={() => handleRsvp("accepted")}
className={userCurrentStatus === "accepted"
? "bg-success hover:bg-success/80 text-success-foreground"
: "text-success border-success/30 hover:bg-success/10"}
>
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
{t("participants.accepted")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
onClick={() => handleRsvp("tentative")}
className={userCurrentStatus === "tentative"
? "bg-warning hover:bg-warning/80 text-warning-foreground"
: "border border-warning/30 text-warning hover:bg-warning/10"}
>
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
{t("participants.tentative")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
onClick={() => handleRsvp("declined")}
className={userCurrentStatus === "declined"
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
: "text-destructive hover:bg-destructive/10"}
>
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
{t("participants.declined")}
</Button>
</div>
<div className="px-6 py-4 border-t border-border flex-shrink-0">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
<div className="flex gap-2">
<Button
size="sm"
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
onClick={() => handleRsvp("accepted")}
className={userCurrentStatus === "accepted"
? "bg-success hover:bg-success/80 text-success-foreground"
: "text-success border-success/30 hover:bg-success/10"}
>
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
{t("participants.accepted")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
onClick={() => handleRsvp("tentative")}
className={userCurrentStatus === "tentative"
? "bg-warning hover:bg-warning/80 text-warning-foreground"
: "border border-warning/30 text-warning hover:bg-warning/10"}
>
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
{t("participants.tentative")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
onClick={() => handleRsvp("declined")}
className={userCurrentStatus === "declined"
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
: "text-destructive hover:bg-destructive/10"}
>
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
{t("participants.declined")}
</Button>
</div>
</div>
</div>
</div>
);
}
@@ -638,7 +640,7 @@ export function EventModal({
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="text-sm">
<span className="font-medium text-foreground">
{format(startD, "EEE, MMM d, yyyy")}
{formatEventDate(startD)}
</span>
{event.showWithoutTime ? (
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
@@ -767,16 +769,16 @@ export function EventModal({
return (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold">
{isEdit ? t("events.edit") : t("events.create")}
</h2>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold">
{isEdit ? t("events.edit") : t("events.create")}
</h2>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto">
<div className="px-6 py-4 space-y-4">
<div>
<label className="text-sm font-medium mb-1 block">{t("form.title")}</label>
@@ -986,69 +988,69 @@ export function EventModal({
</div>
)}
</div>
</div>
</div>
<div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0">
<div className="flex items-center gap-1">
{isEdit && onDelete && (
showDeleteConfirm ? (
<div className="flex items-center gap-2">
<div>
<span className="text-sm text-red-600 dark:text-red-400">
{t("form.delete_confirm")}
</span>
{hasParticipants && (
<p className="text-xs text-muted-foreground mt-0.5">
{t("participants.cancel_notification")}
</p>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
>
{t("events.delete")}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
{t("form.cancel")}
</Button>
<div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0">
<div className="flex items-center gap-1">
{isEdit && onDelete && (
showDeleteConfirm ? (
<div className="flex items-center gap-2">
<div>
<span className="text-sm text-red-600 dark:text-red-400">
{t("form.delete_confirm")}
</span>
{hasParticipants && (
<p className="text-xs text-muted-foreground mt-0.5">
{t("participants.cancel_notification")}
</p>
)}
</div>
) : (
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="text-red-600 dark:text-red-400"
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
>
<Trash2 className="w-4 h-4 mr-1" />
{t("events.delete")}
</Button>
)
)}
{isEdit && onDuplicate && !showDeleteConfirm && (
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
{t("form.cancel")}
</Button>
</div>
) : (
<Button
variant="ghost"
size="sm"
onClick={handleDuplicate}
aria-label={t("events.duplicate")}
onClick={() => setShowDeleteConfirm(true)}
className="text-red-600 dark:text-red-400"
>
<Copy className="w-4 h-4 mr-1" />
{t("events.duplicate")}
<Trash2 className="w-4 h-4 mr-1" />
{t("events.delete")}
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
{t("form.cancel")}
)
)}
{isEdit && onDuplicate && !showDeleteConfirm && (
<Button
variant="ghost"
size="sm"
onClick={handleDuplicate}
aria-label={t("events.duplicate")}
>
<Copy className="w-4 h-4 mr-1" />
{t("events.duplicate")}
</Button>
<Button onClick={handleSave} disabled={!title.trim() || isSaving}>
{t("form.save")}
</Button>
</div>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
{t("form.cancel")}
</Button>
<Button onClick={handleSave} disabled={!title.trim() || isSaving}>
{t("form.save")}
</Button>
</div>
</div>
</div>
);
}
+52 -15
View File
@@ -31,6 +31,7 @@ import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
import { findReplyIdentityId } from "@/lib/reply-identity";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { RichTextEditor } from "@/components/email/rich-text-editor";
/** Strip HTML tags and decode entities to get a plain-text version */
@@ -67,6 +68,8 @@ interface EmailComposerProps {
fromName?: string;
identityId?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[];
references?: string[];
}) => void | Promise<void>;
onClose?: () => void;
onDiscardDraft?: (draftId: string) => void;
@@ -87,6 +90,11 @@ interface EmailComposerProps {
receivedAt?: string;
accountId?: string;
attachments?: Array<{ blobId: string; name?: string; type: string; size: number; cid?: string; disposition?: string }>;
// Threading: parent's Message-ID and References, used to set RFC 5322
// In-Reply-To and References on outgoing replies. See #234.
messageId?: string;
inReplyTo?: string[];
references?: string[];
};
}
@@ -116,6 +124,7 @@ export function EmailComposer({
const tCommon = useTranslations('common');
const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords);
@@ -721,7 +730,7 @@ export function EmailComposer({
// Generate sub-addressed email if tag is set
const fromEmail = currentIdentity?.email
? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag)
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
: currentIdentity.email
: undefined;
@@ -736,7 +745,8 @@ export function EmailComposer({
fromEmail,
draftId || undefined,
uploadedAttachments,
currentIdentity?.name || undefined
currentIdentity?.name || undefined,
plainTextMode ? undefined : body
);
setDraftId(savedDraftId);
@@ -812,19 +822,27 @@ export function EmailComposer({
attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>;
} => {
const known = inlineImagesRef.current;
if (known.length === 0) return { html, attachments: [] };
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
const used = new Map<string, typeof known[number]>();
doc.querySelectorAll('img[data-cid]').forEach((img) => {
const cid = img.getAttribute('data-cid');
if (!cid) return;
const entry = known.find((e) => e.cid === cid);
if (!entry) return;
img.setAttribute('src', `cid:${cid}`);
img.removeAttribute('data-cid');
used.set(cid, entry);
if (known.length > 0) {
doc.querySelectorAll('img[data-cid]').forEach((img) => {
const cid = img.getAttribute('data-cid');
if (!cid) return;
const entry = known.find((e) => e.cid === cid);
if (!entry) return;
img.setAttribute('src', `cid:${cid}`);
img.removeAttribute('data-cid');
used.set(cid, entry);
});
}
// Recipient mail clients apply default <p> margins inside table cells,
// inflating row height. Tiptap wraps cell text in <p>, so force margin:0
// to match the composer's tight rows.
doc.querySelectorAll('td > p, th > p').forEach((p) => {
const existing = p.getAttribute('style') || '';
p.setAttribute('style', `margin:0;${existing}`);
});
return {
@@ -889,7 +907,7 @@ export function EmailComposer({
const fromEmail = currentIdentity?.email
? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag)
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
: currentIdentity.email
: undefined;
@@ -905,6 +923,11 @@ export function EmailComposer({
return '';
};
// RFC 5322 §3.6.4 threading — only continues the chain on a reply, not a forward.
const threadingHeaders = (mode === 'reply' || mode === 'replyAll')
? computeReplyThreadingHeaders(replyTo)
: null;
// In plain text mode, send text/plain only (no HTML body)
const finalBody = plainTextMode
? appendPlainTextSignature(body, currentIdentity)
@@ -968,12 +991,22 @@ export function EmailComposer({
}
// 4. Build canonical MIME
// mime-builder takes inReplyTo as a single ref-form msg-id (with brackets);
// references stays an array. threadingHeaders contains bare msg-ids.
const mimeInReplyTo = threadingHeaders?.inReplyTo[0]
? `<${threadingHeaders.inReplyTo[0]}>`
: undefined;
const mimeReferences = threadingHeaders?.references.length
? threadingHeaders.references.map(id => `<${id}>`)
: undefined;
const mimeBytes = buildMimeMessage({
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
to: toAddresses.map(e => ({ email: e })),
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
bcc: bccAddresses.length > 0 ? bccAddresses.map(e => ({ email: e })) : undefined,
subject,
inReplyTo: mimeInReplyTo,
references: mimeReferences,
textBody: finalBody,
htmlBody: finalHtmlBody,
attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined,
@@ -986,6 +1019,8 @@ export function EmailComposer({
to: toAddresses.map(e => ({ email: e })),
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
subject,
inReplyTo: mimeInReplyTo,
references: mimeReferences,
};
// 5. Sign if enabled
@@ -1042,6 +1077,8 @@ export function EmailComposer({
fromName: currentIdentity?.name || undefined,
identityId: currentIdentity?.id,
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
inReplyTo: threadingHeaders?.inReplyTo,
references: threadingHeaders?.references,
});
}
@@ -1179,7 +1216,7 @@ export function EmailComposer({
>
{identities.map((identity) => {
const displayEmail = subAddressTag
? generateSubAddress(identity.email, subAddressTag)
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
: identity.email;
return (
<option key={identity.id} value={identity.id}>
@@ -1192,7 +1229,7 @@ export function EmailComposer({
<span className="text-sm text-foreground flex-1 truncate">
{subAddressTag ? (
<span className="font-mono">
{generateSubAddress(primaryIdentity?.email || '', subAddressTag)}
{generateSubAddress(primaryIdentity?.email || '', subAddressTag, subAddressDelimiter)}
</span>
) : (
<>
+6 -4
View File
@@ -5,6 +5,7 @@ import { Mail, Tag } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { Email, Identity } from '@/lib/jmap/types';
import { parseSubAddress } from '@/lib/sub-addressing';
import { useSettingsStore } from '@/stores/settings-store';
interface EmailIdentityBadgeProps {
email: Email;
@@ -20,12 +21,13 @@ export function EmailIdentityBadge({
className,
}: EmailIdentityBadgeProps) {
const t = useTranslations('identities.badge');
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
const fromAddress = email.from?.[0]?.email;
if (!fromAddress) return null;
// Parse the from address to check for sub-addressing
const parsedFrom = parseSubAddress(fromAddress);
const parsedFrom = parseSubAddress(fromAddress, subAddressDelimiter);
// Find matching identity (email sent BY the user)
const matchingIdentity = identities.find(
@@ -37,7 +39,7 @@ export function EmailIdentityBadge({
if (!matchingIdentity) {
// Check all TO addresses for sub-address tags matching user's identities
for (const recipient of email.to || []) {
const parsedTo = parseSubAddress(recipient.email);
const parsedTo = parseSubAddress(recipient.email, subAddressDelimiter);
if (parsedTo.tag) {
// Check if this base email matches any of the user's identities
const matchingToIdentity = identities.find(
@@ -70,7 +72,7 @@ export function EmailIdentityBadge({
title={t('sub_address_tag', { tag: displayTag })}
>
<Tag className="w-3 h-3" />
<span className="font-mono">+{displayTag}</span>
<span className="font-mono">{subAddressDelimiter}{displayTag}</span>
</div>
);
}
@@ -114,7 +116,7 @@ export function EmailIdentityBadge({
aria-label={t('sub_address_tag', { tag: displayTag })}
>
<Tag className="w-3 h-3" />
<span className="font-mono">{t('subaddress_tag', { tag: displayTag })}</span>
<span className="font-mono">{subAddressDelimiter}{displayTag}</span>
</div>
)}
+131 -35
View File
@@ -2285,7 +2285,7 @@ export function EmailViewer({
// Sanitize and prepare email HTML content
const emailContent = useMemo(() => {
if (!email) return { html: "", isHtml: false };
if (!email) return { html: "", isHtml: false, hasStyleTag: false };
// Check if we have body values
if (email.bodyValues) {
@@ -2338,8 +2338,7 @@ export function EmailViewer({
);
if (shouldBlockExternal) {
sanitizeConfig.FORBID_TAGS.push('link');
sanitizeConfig.FORBID_ATTR.push('background');
sanitizeConfig.FORBID_TAGS = [...sanitizeConfig.FORBID_TAGS, 'link'];
}
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
@@ -2357,11 +2356,19 @@ export function EmailViewer({
}
}
const bgAttr = node.getAttribute?.('background');
if (bgAttr && (bgAttr.startsWith('http://') || bgAttr.startsWith('https://') || bgAttr.startsWith('//'))) {
node.setAttribute('data-blocked-background', bgAttr);
node.removeAttribute('background');
blockedExternalContent = true;
}
if (htmlNode.style) {
const style = htmlNode.style.cssText;
if (style && style.includes('url(')) {
const urlMatch = style.match(/url\(['"]?(https?:\/\/[^'")\s]+)['"]?\)/gi);
if (urlMatch) {
node.setAttribute('data-blocked-style', style);
htmlNode.style.cssText = style.replace(/url\(['"]?https?:\/\/[^'")\s]+['"]?\)/gi, 'url()');
blockedExternalContent = true;
}
@@ -2395,7 +2402,8 @@ export function EmailViewer({
return {
html: cleanHtml,
isHtml: true
isHtml: true,
hasStyleTag: /<style[\s>]/i.test(htmlContent),
};
}
@@ -2405,7 +2413,8 @@ export function EmailViewer({
return {
html: plainTextToSafeHtml(textContent),
isHtml: false
isHtml: false,
hasStyleTag: false,
};
}
}
@@ -2419,15 +2428,21 @@ export function EmailViewer({
return {
html: `<div style="color: var(--color-muted-foreground); font-style: italic;">${previewHtml}</div>`,
isHtml: false
isHtml: false,
hasStyleTag: false,
};
}
return {
html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
isHtml: false
isHtml: false,
hasStyleTag: false,
};
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, isTrustedAddressBookSender, trustedSendersAddressBook, cidBlobUrls]);
// Intentionally omit allowExternalContent and trust state from deps:
// toggling permission imperatively unblocks content via restoreBlockedContent
// in an effect below, so the iframe srcDoc stays stable and doesn't reload/flash.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email, externalContentPolicy, cidBlobUrls]);
// Override email content with S/MIME decrypted content when available
const effectiveEmailContent = useMemo(() => {
@@ -2439,26 +2454,26 @@ export function EmailViewer({
}
);
const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true };
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(smimeDecryptedHtml) };
}
if (smimeDecryptedText) {
return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false };
return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false, hasStyleTag: false };
}
// TNEF (winmail.dat) extracted content
if (tnefHtml) {
const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true };
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(tnefHtml) };
}
if (tnefText) {
return { html: plainTextToSafeHtml(tnefText), isHtml: false };
return { html: plainTextToSafeHtml(tnefText), isHtml: false, hasStyleTag: false };
}
// Embedded message/rfc822 unwrapped content
if (embeddedEmailHtml) {
const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_IFRAME_SANITIZE_CONFIG);
return { html: cleanHtml, isHtml: true };
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(embeddedEmailHtml) };
}
if (embeddedEmailText) {
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false };
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false, hasStyleTag: false };
}
return emailContent;
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
@@ -2600,10 +2615,14 @@ export function EmailViewer({
const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light';
// Bare HTML emails (no <style>) tend to be plain prose without their own
// layout — give them the same padding as plain-text mails (.email-content-text).
const bodyPadding = effectiveEmailContent.hasStyleTag ? '0' : '1rem 1.25rem';
return `<!DOCTYPE html>
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
body { margin: 0; padding: ${bodyPadding}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
img { max-width: 100% !important; height: auto !important; }
a { color: #1a73e8; }
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
@@ -2613,6 +2632,79 @@ export function EmailViewer({
</style></head><body>${effectiveEmailContent.html}</body></html>`;
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]);
// Imperatively restore blocked external content inside the iframe document.
// Avoids re-rendering the iframe srcDoc (which would reload and flash) when
// the user clicks "Load images" or "Trust sender".
const restoreBlockedContent = useCallback(() => {
const doc = iframeRef.current?.contentDocument;
if (!doc) return;
doc.querySelectorAll('img[data-blocked-src]').forEach((node) => {
const el = node as HTMLImageElement;
const src = el.getAttribute('data-blocked-src');
if (src) {
el.setAttribute('src', src);
el.style.display = '';
el.removeAttribute('data-blocked-src');
}
});
doc.querySelectorAll('[data-blocked-style]').forEach((node) => {
const el = node as HTMLElement;
const style = el.getAttribute('data-blocked-style');
if (style !== null) {
el.style.cssText = style;
el.removeAttribute('data-blocked-style');
}
});
doc.querySelectorAll('[data-blocked-background]').forEach((node) => {
const el = node as HTMLElement;
const bg = el.getAttribute('data-blocked-background');
if (bg) {
el.setAttribute('background', bg);
el.removeAttribute('data-blocked-background');
}
});
doc.querySelectorAll('[data-blocked-collapsed-style]').forEach((node) => {
const el = node as HTMLElement;
const style = el.getAttribute('data-blocked-collapsed-style');
if (style !== null) {
el.style.cssText = style;
el.removeAttribute('data-blocked-collapsed-style');
}
});
}, []);
// Whenever permission is granted (allow toggled, or sender becomes trusted),
// restore blocked content in the existing iframe — no srcDoc rebuild.
const senderEmailLower = email?.from?.[0]?.email?.toLowerCase();
const senderIsTrustedNow = senderEmailLower
? isSenderTrusted(senderEmailLower) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmailLower))
: false;
useEffect(() => {
if (!hasBlockedContent) return;
if (!allowExternalContent && !senderIsTrustedNow) return;
restoreBlockedContent();
}, [allowExternalContent, senderIsTrustedNow, hasBlockedContent, restoreBlockedContent]);
// Tracks the last rendered body height so the loading skeleton can hold
// the same size — avoids the body shrink/expand flash when switching emails.
const lastBodyHeightRef = useRef<number>(300);
// True while the new email's body is still being fetched. Catches the
// window between selectedEmail changing and isLoading flipping true, so the
// quick reply / body don't flicker through a partial render.
const isBodyLoading = isLoading || !email?.bodyValues || Object.keys(email.bodyValues).length === 0;
// Gates the quick reply on the iframe having loaded the current srcDoc, so
// it doesn't flash in below a still-resizing iframe.
const [iframeReady, setIframeReady] = useState(false);
useLayoutEffect(() => {
setIframeReady(false);
}, [emailIframeSrcDoc]);
const handleIframeLoad = useCallback(() => {
const iframe = iframeRef.current;
if (!iframe) return;
@@ -2623,9 +2715,13 @@ export function EmailViewer({
const resizeObserver = new ResizeObserver(() => {
const height = doc.documentElement.scrollHeight;
iframe.style.height = height + 'px';
lastBodyHeightRef.current = height;
});
resizeObserver.observe(doc.body);
iframe.style.height = doc.documentElement.scrollHeight + 'px';
const initialHeight = doc.documentElement.scrollHeight;
iframe.style.height = initialHeight + 'px';
lastBodyHeightRef.current = initialHeight;
setIframeReady(true);
// Make links open in new tab
doc.querySelectorAll('a').forEach(a => {
@@ -2946,11 +3042,6 @@ export function EmailViewer({
{/* Right: Organize actions - order: archive, delete, move, star, tag, spam, read state, print, view source */}
<div className="flex items-center gap-0 sm:gap-0.5">
{isLoading && (
<div className="mr-2 flex items-center gap-1.5 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
</div>
)}
{/* Archive */}
<Button
variant="ghost"
@@ -3629,15 +3720,6 @@ export function EmailViewer({
)}
{/* Main email content */}
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
{/* Loading overlay when fetching new email */}
{isLoading && (
<div className="absolute inset-0 bg-background/60 backdrop-blur-[2px] z-50 flex items-center justify-center animate-in fade-in duration-200">
<div className="bg-background rounded-lg shadow-lg border border-border p-4 flex items-center gap-3">
<Loader2 className="w-5 h-5 animate-spin text-primary" />
<span className="text-sm font-medium text-foreground">{t('loading_email')}</span>
</div>
</div>
)}
{/* === TOOLBAR (top position) === */}
{toolbarPosition === 'top' && (
<div className={cn(
@@ -4634,14 +4716,28 @@ export function EmailViewer({
<PluginSlot name="email-banner" extraProps={{ email }} />
{/* Email Body */}
<div className="email-content-wrapper overflow-x-auto">
{effectiveEmailContent.isHtml ? (
<div className={cn(
"email-content-wrapper overflow-x-auto",
emailAlwaysLightMode ? "bg-white email-content-light" : "bg-background"
)}>
{isBodyLoading ? (
<div
className="space-y-3 px-6 py-4 animate-pulse"
style={{ minHeight: `${lastBodyHeightRef.current}px` }}
>
<div className="h-2 bg-muted/15 rounded w-full"></div>
<div className="h-2 bg-muted/15 rounded w-5/6"></div>
<div className="h-2 bg-muted/15 rounded w-4/6"></div>
<div className="h-2 bg-muted/15 rounded w-full"></div>
<div className="h-2 bg-muted/15 rounded w-3/4"></div>
</div>
) : effectiveEmailContent.isHtml ? (
<iframe
ref={iframeRef}
srcDoc={emailIframeSrcDoc}
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
title="Email content"
className="w-full border-0 rounded"
className="w-full border-0"
style={{ minHeight: '100px', colorScheme: isDark && emailHasNativeDarkMode ? 'light dark' : 'light' }}
onLoad={handleIframeLoad}
/>
@@ -4662,8 +4758,8 @@ export function EmailViewer({
<PluginSlot name="email-footer" />
{/* Quick Reply Section - hidden for drafts */}
{!isDraft && (<div className={cn(
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className={cn(
"mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all",
isQuickReplyFocused || quickReplyText ? "border-primary" : "border-border"
)}>
+164 -1
View File
@@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useCallback } from "react";
import React, { useEffect, useCallback, useState, useRef } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
@@ -10,6 +10,10 @@ import { TextStyle } from "@tiptap/extension-text-style";
import Color from "@tiptap/extension-color";
import { ResizableImage } from "@/components/email/resizable-image";
import Placeholder from "@tiptap/extension-placeholder";
import { Table } from "@tiptap/extension-table";
import { TableRow } from "@tiptap/extension-table-row";
import { TableHeader } from "@tiptap/extension-table-header";
import { TableCell } from "@tiptap/extension-table-cell";
import { cn } from "@/lib/utils";
import {
Bold,
@@ -29,6 +33,10 @@ import {
RemoveFormatting,
Heading1,
Heading2,
Table as TableIcon,
Trash2,
Rows3,
Columns3,
} from "lucide-react";
export interface InlineImageUpload {
@@ -79,6 +87,43 @@ function ToolbarSeparator() {
return <div className="w-px h-5 bg-border mx-0.5" />;
}
const TABLE_PICKER_ROWS = 6;
const TABLE_PICKER_COLS = 8;
function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
return (
<div>
<div
className="grid gap-0.5"
style={{ gridTemplateColumns: `repeat(${TABLE_PICKER_COLS}, 1fr)` }}
onMouseLeave={() => setHover(null)}
>
{Array.from({ length: TABLE_PICKER_ROWS * TABLE_PICKER_COLS }).map((_, i) => {
const r = Math.floor(i / TABLE_PICKER_COLS);
const c = i % TABLE_PICKER_COLS;
const active = hover && r <= hover.r && c <= hover.c;
return (
<button
key={i}
type="button"
onMouseEnter={() => setHover({ r, c })}
onClick={() => onPick(r + 1, c + 1)}
className={cn(
"w-4 h-4 border border-border/60 rounded-[2px] transition-colors",
active ? "bg-primary border-primary" : "bg-background hover:bg-accent"
)}
/>
);
})}
</div>
<div className="text-xs text-muted-foreground mt-1.5 text-center">
{hover ? `${hover.r + 1} × ${hover.c + 1}` : "Pick size"}
</div>
</div>
);
}
export function RichTextEditor({
content,
onChange,
@@ -109,6 +154,27 @@ export function RichTextEditor({
Placeholder.configure({
placeholder,
}),
Table.configure({
resizable: true,
HTMLAttributes: {
border: "1",
cellpadding: "6",
cellspacing: "0",
width: "100%",
style: "width:100%;border-collapse:collapse;",
},
}),
TableRow,
TableHeader.configure({
HTMLAttributes: {
style: "padding:6px 8px;border:1px solid #ccc;background-color:#f5f5f5;color:#1f2937;text-align:left;",
},
}),
TableCell.configure({
HTMLAttributes: {
style: "padding:6px 8px;border:1px solid #ccc;vertical-align:top;",
},
}),
],
content,
editorProps: {
@@ -188,6 +254,20 @@ export function RichTextEditor({
.run();
}, [editor]);
const [tableMenuOpen, setTableMenuOpen] = useState(false);
const tableWrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!tableMenuOpen) return;
const handler = (e: MouseEvent) => {
if (tableWrapperRef.current && !tableWrapperRef.current.contains(e.target as Node)) {
setTableMenuOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [tableMenuOpen]);
if (!editor) {
return (
<div className={cn("min-h-[100px]", className)} />
@@ -309,6 +389,89 @@ export function RichTextEditor({
<LinkIcon className="w-4 h-4" />
</ToolbarButton>
<div ref={tableWrapperRef} className="relative">
<ToolbarButton
active={editor.isActive("table")}
onClick={() => setTableMenuOpen((v) => !v)}
title="Table"
>
<TableIcon className="w-4 h-4" />
</ToolbarButton>
{tableMenuOpen && (
<div className="absolute z-50 top-full left-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2 min-w-[200px]">
{editor.isActive("table") ? (
<div className="flex flex-col gap-0.5">
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().addRowBefore().run(); setTableMenuOpen(false); }}
>
<Rows3 className="w-4 h-4" /> Add row above
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().addRowAfter().run(); setTableMenuOpen(false); }}
>
<Rows3 className="w-4 h-4" /> Add row below
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().addColumnBefore().run(); setTableMenuOpen(false); }}
>
<Columns3 className="w-4 h-4" /> Add column before
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().addColumnAfter().run(); setTableMenuOpen(false); }}
>
<Columns3 className="w-4 h-4" /> Add column after
</button>
<div className="h-px bg-border my-1" />
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().deleteRow().run(); setTableMenuOpen(false); }}
>
<Trash2 className="w-4 h-4" /> Delete row
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().deleteColumn().run(); setTableMenuOpen(false); }}
>
<Trash2 className="w-4 h-4" /> Delete column
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
onClick={() => { editor.chain().focus().toggleHeaderRow().run(); setTableMenuOpen(false); }}
>
<Rows3 className="w-4 h-4" /> Toggle header row
</button>
<div className="h-px bg-border my-1" />
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left text-red-600 dark:text-red-400"
onClick={() => { editor.chain().focus().deleteTable().run(); setTableMenuOpen(false); }}
>
<Trash2 className="w-4 h-4" /> Delete table
</button>
</div>
) : (
<TableSizePicker
onPick={(rows, cols) => {
editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run();
setTableMenuOpen(false);
}}
/>
)}
</div>
)}
</div>
<ToolbarSeparator />
<ToolbarButton
+4 -2
View File
@@ -7,6 +7,7 @@ import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useIdentityStore } from '@/stores/identity-store';
import { useSettingsStore } from '@/stores/settings-store';
import {
generateSubAddress,
extractDomain,
@@ -35,6 +36,7 @@ export function SubAddressHelper({
const popoverRef = useRef<HTMLDivElement>(null);
const { subAddress, addRecentTag, addTagSuggestion } = useIdentityStore();
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
// Get suggestions based on recipient (memoized for performance)
const suggestions = useMemo(() => {
@@ -47,7 +49,7 @@ export function SubAddressHelper({
}, [recipientEmails]);
// Generate preview
const preview = tag ? generateSubAddress(baseEmail, tag) : baseEmail;
const preview = tag ? generateSubAddress(baseEmail, tag, subAddressDelimiter) : baseEmail;
// Close popover when clicking outside
useEffect(() => {
@@ -226,7 +228,7 @@ export function SubAddressHelper({
{/* Help Text */}
<div className="mb-3 text-xs text-muted-foreground">
{t('help_text')}
{t('help_text', { delimiter: subAddressDelimiter })}
</div>
{/* Use Address Button */}
+3 -1
View File
@@ -16,12 +16,13 @@ import nlMessages from '@/locales/nl/common.json';
import plMessages from '@/locales/pl/common.json';
import ptMessages from '@/locales/pt/common.json';
import ruMessages from '@/locales/ru/common.json';
import trMessages from '@/locales/tr/common.json';
import ukMessages from '@/locales/uk/common.json';
import zhMessages from '@/locales/zh/common.json';
// Pre-loaded translations (loaded at build time, not runtime)
const ALL_MESSAGES = {
cs: csMessages,
cs: csMessages,
en: enMessages,
fr: frMessages,
ja: jaMessages,
@@ -34,6 +35,7 @@ const ALL_MESSAGES = {
pl: plMessages,
pt: ptMessages,
ru: ruMessages,
tr: trMessages,
uk: ukMessages,
zh: zhMessages,
};
+53 -1
View File
@@ -4,8 +4,16 @@ import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { Mail, X } from 'lucide-react';
import {
SUPPORTED_SUB_ADDRESS_DELIMITERS,
isSupportedSubAddressDelimiter,
isValidSubAddressDelimiter,
} from '@/lib/sub-addressing';
const CUSTOM_DELIMITER_SENTINEL = '__custom__';
const DEFAULT_CUSTOM_DELIMITER = '~';
export function ComposingSettings() {
const t = useTranslations('settings.email_behavior');
@@ -17,6 +25,7 @@ export function ComposingSettings() {
autoSelectReplyIdentity,
attachmentReminderEnabled,
attachmentReminderKeywords,
subAddressDelimiter,
updateSetting,
} = useSettingsStore();
@@ -40,6 +49,49 @@ export function ComposingSettings() {
/>
</SettingItem>
<SettingItem
label={t('sub_address_delimiter.label')}
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
>
<div className="flex flex-col items-end gap-2">
<Select
value={isSupportedSubAddressDelimiter(subAddressDelimiter) ? subAddressDelimiter : CUSTOM_DELIMITER_SENTINEL}
onChange={(value) => {
if (value === CUSTOM_DELIMITER_SENTINEL) {
if (isSupportedSubAddressDelimiter(subAddressDelimiter)) {
updateSetting('subAddressDelimiter', DEFAULT_CUSTOM_DELIMITER);
}
} else {
updateSetting('subAddressDelimiter', value);
}
}}
options={[
...SUPPORTED_SUB_ADDRESS_DELIMITERS.map((delim) => ({
value: delim,
label: t('sub_address_delimiter.option', { delimiter: delim }),
})),
{ value: CUSTOM_DELIMITER_SENTINEL, label: t('sub_address_delimiter.custom') },
]}
/>
{!isSupportedSubAddressDelimiter(subAddressDelimiter) && (
<input
type="text"
maxLength={1}
value={subAddressDelimiter}
onChange={(e) => {
const next = e.target.value.slice(0, 1);
if (next && isValidSubAddressDelimiter(next)) {
updateSetting('subAddressDelimiter', next);
}
}}
aria-label={t('sub_address_delimiter.custom_input_label')}
placeholder={DEFAULT_CUSTOM_DELIMITER}
className="w-16 px-2 py-1 text-sm font-mono text-center bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
/>
)}
</div>
</SettingItem>
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
<ToggleSwitch
checked={attachmentReminderEnabled}
+170 -1
View File
@@ -1,13 +1,32 @@
"use client";
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
import type { NotificationSoundChoice } from '@/lib/notification-sound';
import { Button } from '@/components/ui/button';
import { Volume2 } from 'lucide-react';
import { CheckCircle2, Volume2, XCircle } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import {
DEFAULT_RELAY_BASE_URL,
WebPushUnsupportedError,
disableWebPush,
enableWebPush,
isWebPushEnabled,
isWebPushSupported,
} from '@/lib/web-push';
type PushStatus =
| { kind: 'idle' }
| { kind: 'busy' }
| { kind: 'enabled' }
| { kind: 'unsupported' }
| { kind: 'error'; message: string };
export function NotificationSettings() {
const t = useTranslations('settings.notifications');
@@ -21,6 +40,77 @@ export function NotificationSettings() {
updateSetting,
} = useSettingsStore();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
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 [pushStatus, setPushStatus] = useState<PushStatus>(
supported ? { kind: 'idle' } : { kind: 'unsupported' },
);
useEffect(() => {
if (!supported) return;
void (async () => {
const enabled = await isWebPushEnabled();
if (enabled) setPushStatus({ kind: 'enabled' });
})();
}, [supported]);
const trimmedRelay = relayUrl.trim().replace(/\/+$/, '');
const isValidRelay = /^https?:\/\/.+/i.test(trimmedRelay);
const busy = pushStatus.kind === 'busy';
const handleEnablePush = async () => {
if (!client) {
setPushStatus({ kind: 'error', message: 'Sign in first' });
return;
}
if (!isValidRelay) {
setPushStatus({ kind: 'error', message: 'Enter a valid https:// URL' });
return;
}
setPushStatus({ kind: 'busy' });
try {
await enableWebPush({
client,
relayBaseUrl: trimmedRelay,
accountLabel: username ?? undefined,
});
setPushStatus({ kind: 'enabled' });
} catch (err) {
if (err instanceof WebPushUnsupportedError) {
setPushStatus({ kind: 'unsupported' });
return;
}
setPushStatus({
kind: 'error',
message: err instanceof Error ? err.message : 'Failed to enable push',
});
}
};
const handleDisablePush = async () => {
if (!client) return;
const confirmed = await confirmDialog({
title: t('push.confirm_disable_title'),
message: t('push.confirm_disable_message'),
confirmText: t('push.disable'),
variant: 'destructive',
});
if (!confirmed) return;
setPushStatus({ kind: 'busy' });
try {
await disableWebPush({ client, relayBaseUrl: trimmedRelay });
setPushStatus({ kind: 'idle' });
} catch (err) {
setPushStatus({
kind: 'error',
message: err instanceof Error ? err.message : 'Failed to disable push',
});
}
};
const soundOptions = NOTIFICATION_SOUNDS.map((s) => ({
value: s.id,
@@ -29,6 +119,46 @@ export function NotificationSettings() {
return (
<div className="space-y-8">
<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">
{t('push.relay_label')}
</label>
<PushStatusBadge status={pushStatus} t={t} />
</div>
<p className="text-xs text-muted-foreground">{t('push.relay_desc')}</p>
<input
id="push-relay-url"
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
value={relayUrl}
onChange={(e) => setRelayUrl(e.target.value)}
placeholder={t('push.relay_placeholder')}
disabled={busy || pushStatus.kind === 'unsupported'}
className="w-full rounded border bg-background px-3 py-2 text-sm disabled:opacity-50"
/>
<div className="flex flex-wrap gap-2">
<Button
onClick={handleEnablePush}
disabled={busy || pushStatus.kind === 'unsupported' || !isValidRelay || !client}
>
{pushStatus.kind === 'enabled' ? t('push.reenable') : t('push.enable')}
</Button>
{pushStatus.kind === 'enabled' && (
<Button variant="outline" onClick={handleDisablePush} disabled={busy}>
{t('push.disable')}
</Button>
)}
</div>
{pushStatus.kind === 'unsupported' && (
<p className="text-xs text-muted-foreground">{t('push.ios_hint')}</p>
)}
</div>
</SettingsSection>
<SettingsSection title={t('sound_selection.title')} description={t('sound_selection.description')}>
<SettingItem
label={t('sound_selection.choose')}
@@ -118,6 +248,45 @@ export function NotificationSettings() {
/>
</SettingItem>
</SettingsSection>
<ConfirmDialog {...confirmDialogProps} />
</div>
);
}
function PushStatusBadge({
status,
t,
}: {
status: PushStatus;
t: ReturnType<typeof useTranslations>;
}) {
if (status.kind === 'enabled') {
return (
<span className="inline-flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="w-3.5 h-3.5" />
{t('push.status_active')}
</span>
);
}
if (status.kind === 'busy') {
return <span className="text-xs text-muted-foreground">{t('push.status_busy')}</span>;
}
if (status.kind === 'unsupported') {
return (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<XCircle className="w-3.5 h-3.5" />
{t('push.status_unsupported')}
</span>
);
}
if (status.kind === 'error') {
return (
<span className="inline-flex items-center gap-1 text-xs text-destructive" title={status.message}>
<XCircle className="w-3.5 h-3.5" />
{status.message}
</span>
);
}
return <span className="text-xs text-muted-foreground">{t('push.status_inactive')}</span>;
}
+13
View File
@@ -152,6 +152,18 @@ export function FlagRU(props: FlagProps) {
);
}
/** Turkey - Red with white crescent and star */
export function FlagTR(props: FlagProps) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 20" width={W} height={H} className={flagClass} {...props}>
<rect width="30" height="20" fill="#E30A17" />
<circle cx="10" cy="10" r="6" fill="#fff" />
<circle cx="11.5" cy="10" r="5" fill="#E30A17" />
<polygon points="19.5,7.8 19.994,9.32 21.592,9.32 20.299,10.26 20.793,11.78 19.5,10.84 18.207,11.78 18.701,10.26 17.408,9.32 19.006,9.32" fill="#fff" />
</svg>
);
}
/** Ukraine Blue, Yellow horizontal */
export function FlagUA(props: FlagProps) {
return (
@@ -192,6 +204,7 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
pl: FlagPL,
pt: FlagBR,
ru: FlagRU,
tr: FlagTR,
uk: FlagUA,
zh: FlagCN,
};
+2 -1
View File
@@ -8,7 +8,7 @@ import { cn } from '@/lib/utils';
import { flagComponents } from './flag-icons';
const languages = [
{ value: 'cs', label: 'Česky' },
{ value: 'cs', label: 'Česky' },
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'ja', label: '日本語' },
@@ -21,6 +21,7 @@ const languages = [
{ value: 'pl', label: 'Polski' },
{ value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' },
{ value: 'tr', label: 'Türkçe' },
{ value: 'uk', label: 'Українська' },
{ value: 'zh', label: '简体中文' },
];
+27
View File
@@ -0,0 +1,27 @@
import { useCallback } from "react";
import { useTranslations } from "next-intl";
import { format } from "date-fns";
/**
* Returns a memoized function that formats a calendar event date
* using the current locale for day and month names.
*
* The string will be in the format: "EEE, MMM d, yyyy"
*
* For example: "Wed, Apr 29, 2026" (en)
* "Qua, Abr 29, 2026" (pt)
*/
export function useFormatEventDate(): (date: Date) => string {
const t = useTranslations("calendar");
return useCallback(
(date: Date): string => {
const dayOfWeek = format(date, "EEE").toLowerCase();
const month = format(date, "MMM").toLowerCase();
const day = format(date, "d");
const year = format(date, "yyyy");
return `${t(`days.${dayOfWeek}`)}, ${t(`months.${month}`)} ${day}, ${year}`;
},
[t]
);
}
+3
View File
@@ -47,6 +47,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
case 'ru':
messages = (await import('../locales/ru/common.json')).default;
break;
case 'tr':
messages = (await import('../locales/tr/common.json')).default;
break;
case 'uk':
messages = (await import('../locales/uk/common.json')).default;
break;
+1 -1
View File
@@ -13,7 +13,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
| 'as-needed';
export const routing = defineRouting({
locales: ['cs', 'en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'uk', 'zh'],
locales: ['cs', 'en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'],
defaultLocale: 'en',
localePrefix
});
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import {
computeReplyThreadingHeaders,
stripMessageIdBrackets,
} from '../email-threading';
describe('stripMessageIdBrackets', () => {
it('strips surrounding angle brackets', () => {
expect(stripMessageIdBrackets('<abc@example.com>')).toBe('abc@example.com');
});
it('handles whitespace and missing brackets', () => {
expect(stripMessageIdBrackets(' abc@example.com ')).toBe('abc@example.com');
expect(stripMessageIdBrackets('<abc@example.com')).toBe('abc@example.com');
expect(stripMessageIdBrackets('abc@example.com>')).toBe('abc@example.com');
});
});
describe('computeReplyThreadingHeaders', () => {
it('returns null when the parent has no Message-ID', () => {
expect(computeReplyThreadingHeaders(undefined)).toBeNull();
expect(computeReplyThreadingHeaders({})).toBeNull();
expect(computeReplyThreadingHeaders({ messageId: '' })).toBeNull();
expect(computeReplyThreadingHeaders({ messageId: ' ' })).toBeNull();
});
it('sets In-Reply-To to the parent Message-ID and seeds References with it', () => {
const result = computeReplyThreadingHeaders({
messageId: '<root@example.com>',
});
expect(result).toEqual({
inReplyTo: ['root@example.com'],
references: ['root@example.com'],
});
});
it('appends the parent to existing References per RFC 5322', () => {
const result = computeReplyThreadingHeaders({
messageId: '<msg-2@example.com>',
references: ['<msg-0@example.com>', '<msg-1@example.com>'],
});
expect(result).toEqual({
inReplyTo: ['msg-2@example.com'],
references: ['msg-0@example.com', 'msg-1@example.com', 'msg-2@example.com'],
});
});
it('de-duplicates if the parent already appears in References', () => {
const result = computeReplyThreadingHeaders({
messageId: '<msg-1@example.com>',
references: ['<msg-0@example.com>', '<msg-1@example.com>'],
});
expect(result?.references).toEqual([
'msg-0@example.com',
'msg-1@example.com',
]);
});
it('accepts bare Message-IDs without angle brackets', () => {
const result = computeReplyThreadingHeaders({
messageId: 'msg-2@example.com',
references: ['msg-1@example.com'],
});
expect(result).toEqual({
inReplyTo: ['msg-2@example.com'],
references: ['msg-1@example.com', 'msg-2@example.com'],
});
});
// JMAP RFC 8621 §4.1.2.3 returns messageId as String[]|null. Verify we
// don't crash on that shape even though most call sites pass a string.
it('accepts an array-shaped messageId per JMAP spec', () => {
const result = computeReplyThreadingHeaders({
messageId: ['<msg-2@example.com>'],
references: ['<msg-1@example.com>'],
});
expect(result).toEqual({
inReplyTo: ['msg-2@example.com'],
references: ['msg-1@example.com', 'msg-2@example.com'],
});
});
it('returns null for an empty messageId array', () => {
expect(computeReplyThreadingHeaders({ messageId: [] })).toBeNull();
});
});
+151
View File
@@ -0,0 +1,151 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
function createClient(): JMAPClient {
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
Object.assign(client, {
apiUrl: 'https://jmap.example.com/api',
accountId: 'account-1',
username: 'user@example.com',
});
return client;
}
interface JMAPMethodCall {
0: string;
1: Record<string, unknown>;
2: string;
}
interface CapturedRequest {
using?: string[];
methodCalls: JMAPMethodCall[];
}
/**
* Mock fetch to script three sequential JMAP requests sendEmail makes:
* Mailbox/get → Identity/get → Email/set + EmailSubmission/set.
* Returns the captured request bodies for assertions.
*/
function mockSendEmailFlow() {
const captured: CapturedRequest[] = [];
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockImplementation(async (_url, init) => {
const body = JSON.parse((init as { body: string }).body) as CapturedRequest;
captured.push(body);
const callIdx = captured.length - 1;
let payload: unknown;
if (callIdx === 0) {
payload = {
methodResponses: [[
'Mailbox/get',
{
list: [
{ id: 'mb-drafts', name: 'Drafts', role: 'drafts' },
{ id: 'mb-sent', name: 'Sent', role: 'sent' },
],
},
'0',
]],
};
} else if (callIdx === 1) {
payload = {
methodResponses: [[
'Identity/get',
{ list: [{ id: 'identity-1', email: 'user@example.com', mayDelete: false }] },
'0',
]],
};
} else {
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'],
],
};
}
return {
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify(payload)),
json: () => Promise.resolve(payload),
} as Response;
});
return captured;
}
describe('JMAPClient.sendEmail threading headers', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('writes inReplyTo and references on the Email/set create when supplied', async () => {
const client = createClient();
const captured = mockSendEmailFlow();
await client.sendEmail(
['recipient@example.com'],
'Re: testmail',
'reply body',
undefined, undefined, 'identity-1', 'user@example.com',
undefined, undefined, undefined, undefined,
['<parent@example.com>'],
['<root@example.com>', '<parent@example.com>'],
);
// Third request is the Email/set + EmailSubmission/set batch.
const setCall = captured[2].methodCalls[0];
expect(setCall[0]).toBe('Email/set');
const create = setCall[1].create as Record<string, Record<string, unknown>>;
const draft = Object.values(create)[0];
// Bare msg-ids per RFC 8621 — angle brackets stripped.
expect(draft.inReplyTo).toEqual(['parent@example.com']);
expect(draft.references).toEqual(['root@example.com', 'parent@example.com']);
});
it('omits threading fields when no parent ids are supplied', async () => {
const client = createClient();
const captured = mockSendEmailFlow();
await client.sendEmail(
['recipient@example.com'],
'Fresh thread',
'body',
undefined, undefined, 'identity-1', 'user@example.com',
);
const setCall = captured[2].methodCalls[0];
const create = setCall[1].create as Record<string, Record<string, unknown>>;
const draft = Object.values(create)[0];
expect(draft.inReplyTo).toBeUndefined();
expect(draft.references).toBeUndefined();
});
it('drops empty / whitespace-only ids rather than sending blank entries', async () => {
const client = createClient();
const captured = mockSendEmailFlow();
await client.sendEmail(
['recipient@example.com'],
'Re: testmail',
'body',
undefined, undefined, 'identity-1', 'user@example.com',
undefined, undefined, undefined, undefined,
['<>', ' ', '<real@example.com>'],
[],
);
const setCall = captured[2].methodCalls[0];
const create = setCall[1].create as Record<string, Record<string, unknown>>;
const draft = Object.values(create)[0];
expect(draft.inReplyTo).toEqual(['real@example.com']);
expect(draft.references).toBeUndefined();
});
});
+123
View File
@@ -6,6 +6,10 @@ import {
suggestTagsForDomain,
isValidTag,
getTagValidationError,
isSupportedSubAddressDelimiter,
isValidSubAddressDelimiter,
SUPPORTED_SUB_ADDRESS_DELIMITERS,
DEFAULT_SUB_ADDRESS_DELIMITER,
MAX_TAG_LENGTH,
} from '../sub-addressing';
@@ -354,3 +358,122 @@ describe('getTagValidationError', () => {
expect(getTagValidationError('日本語')).toBe('INVALID_CHARS');
});
});
describe('custom delimiter', () => {
describe('parseSubAddress with non-default delimiter', () => {
it('should parse with "-" delimiter', () => {
const result = parseSubAddress('user-shopping@example.com', '-');
expect(result.baseUser).toBe('user');
expect(result.tag).toBe('shopping');
});
it('should parse with "." delimiter', () => {
const result = parseSubAddress('user.shopping@example.com', '.');
expect(result.baseUser).toBe('user');
expect(result.tag).toBe('shopping');
});
it('should parse with "=" delimiter', () => {
const result = parseSubAddress('user=shopping@example.com', '=');
expect(result.baseUser).toBe('user');
expect(result.tag).toBe('shopping');
});
it('should ignore "+" when "-" is configured as the delimiter', () => {
const result = parseSubAddress('user+shopping@example.com', '-');
expect(result.baseUser).toBe('user+shopping');
expect(result.tag).toBeNull();
});
it('should split on first occurrence when delimiter appears multiple times', () => {
const result = parseSubAddress('alice-shop-orders@example.com', '-');
expect(result.baseUser).toBe('alice');
expect(result.tag).toBe('shop-orders');
});
});
describe('generateSubAddress with non-default delimiter', () => {
it('should generate using "-" delimiter', () => {
expect(generateSubAddress('user@example.com', 'shopping', '-')).toBe('user-shopping@example.com');
});
it('should generate using "." delimiter', () => {
expect(generateSubAddress('user@example.com', 'shopping', '.')).toBe('user.shopping@example.com');
});
it('should replace existing tag using the configured delimiter', () => {
expect(generateSubAddress('user-old@example.com', 'new', '-')).toBe('user-new@example.com');
});
it('should not strip a "+" sign in the local part when delimiter is "-"', () => {
// "+" is not the delimiter so it should remain part of the base user
expect(generateSubAddress('user+plus@example.com', 'tag', '-')).toBe('user+plus-tag@example.com');
});
});
describe('isSupportedSubAddressDelimiter', () => {
it('accepts every supported delimiter', () => {
for (const delim of SUPPORTED_SUB_ADDRESS_DELIMITERS) {
expect(isSupportedSubAddressDelimiter(delim)).toBe(true);
}
});
it('rejects unsupported characters', () => {
expect(isSupportedSubAddressDelimiter('_')).toBe(false);
expect(isSupportedSubAddressDelimiter('++')).toBe(false);
expect(isSupportedSubAddressDelimiter('')).toBe(false);
});
it('default delimiter is supported', () => {
expect(isSupportedSubAddressDelimiter(DEFAULT_SUB_ADDRESS_DELIMITER)).toBe(true);
});
});
describe('isValidSubAddressDelimiter', () => {
it('accepts every preset delimiter', () => {
for (const delim of SUPPORTED_SUB_ADDRESS_DELIMITERS) {
expect(isValidSubAddressDelimiter(delim)).toBe(true);
}
});
it('accepts atext special characters as custom delimiters', () => {
const customs = ['~', '!', '#', '$', '%', '&', "'", '*', '/', '?', '^', '_', '`', '{', '|', '}'];
for (const c of customs) {
expect(isValidSubAddressDelimiter(c)).toBe(true);
}
});
it('rejects alphanumeric characters', () => {
expect(isValidSubAddressDelimiter('a')).toBe(false);
expect(isValidSubAddressDelimiter('Z')).toBe(false);
expect(isValidSubAddressDelimiter('0')).toBe(false);
});
it('rejects "@", whitespace, and quotes', () => {
expect(isValidSubAddressDelimiter('@')).toBe(false);
expect(isValidSubAddressDelimiter(' ')).toBe(false);
expect(isValidSubAddressDelimiter('\t')).toBe(false);
expect(isValidSubAddressDelimiter('"')).toBe(false);
});
it('rejects multi-character strings', () => {
expect(isValidSubAddressDelimiter('++')).toBe(false);
expect(isValidSubAddressDelimiter('abc')).toBe(false);
});
it('rejects empty / non-string inputs', () => {
expect(isValidSubAddressDelimiter('')).toBe(false);
expect(isValidSubAddressDelimiter(null)).toBe(false);
expect(isValidSubAddressDelimiter(undefined)).toBe(false);
expect(isValidSubAddressDelimiter(1)).toBe(false);
});
it('round-trips through parse/generate with a custom "~" delimiter', () => {
const generated = generateSubAddress('user@example.com', 'shopping', '~');
expect(generated).toBe('user~shopping@example.com');
const parsed = parseSubAddress(generated, '~');
expect(parsed.baseUser).toBe('user');
expect(parsed.tag).toBe('shopping');
});
});
});
+34
View File
@@ -138,4 +138,38 @@ describe('verifyJmapAuth SSRF protection', () => {
});
expect(fetchSpy).not.toHaveBeenCalled();
});
it('with trusted=true, accepts a hostname resolving to a private IP', async () => {
lookup.mockResolvedValue([{ address: '10.0.20.5', family: 4 }]);
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ apiUrl: 'https://mail.internal/api', accounts: {} }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
const { verifyJmapAuth } = await load();
await expect(
verifyJmapAuth('https://mail.internal', 'Bearer x', { trusted: true }),
).resolves.toBe('https://mail.internal');
expect(fetchSpy).toHaveBeenCalledWith(
'https://mail.internal/.well-known/jmap',
expect.objectContaining({ redirect: 'manual' }),
);
});
it('with trusted=true, still rejects unsupported protocols', async () => {
const { verifyJmapAuth } = await load();
await expect(
verifyJmapAuth('file:///etc/passwd', 'Bearer x', { trusted: true }),
).rejects.toMatchObject({ status: 400 });
expect(fetchSpy).not.toHaveBeenCalled();
});
it('with trusted=true, still rejects an invalid Authorization header', async () => {
const { verifyJmapAuth } = await load();
await expect(
verifyJmapAuth('https://mail.internal', 'NotAuth', { trusted: true }),
).rejects.toMatchObject({ status: 400 });
expect(fetchSpy).not.toHaveBeenCalled();
});
});
+7 -3
View File
@@ -40,11 +40,15 @@ export function validateProxyAuthHeader(authHeader: string): void {
}
}
export async function verifyJmapAuth(serverUrl: string, authHeader: string): Promise<string> {
export async function verifyJmapAuth(
serverUrl: string,
authHeader: string,
options: { trusted?: boolean } = {},
): Promise<string> {
const normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
validateProxyAuthHeader(authHeader);
if (!(await isPublicHttpUrl(normalizedServerUrl))) {
if (!options.trusted && !(await isPublicHttpUrl(normalizedServerUrl))) {
throw new JmapAuthVerificationError('Server URL is not allowed', 400);
}
@@ -56,7 +60,7 @@ export async function verifyJmapAuth(serverUrl: string, authHeader: string): Pro
let response: Response | undefined;
for (let i = 0; i <= MAX_REDIRECTS; i++) {
if (!(await isPublicHttpUrl(currentUrl))) {
if (!options.trusted && !(await isPublicHttpUrl(currentUrl))) {
throw new JmapAuthVerificationError('Server URL is not allowed', 400);
}
+24 -3
View File
@@ -95,6 +95,18 @@ export class DemoJMAPClient implements IJMAPClient {
getLastStates(): AccountStates { return { ...this.lastStates }; }
setLastStates(states: AccountStates): void { this.lastStates = { ...states }; }
// PushSubscription endpoints have no meaning in demo mode - the demo client
// never makes real network calls so there's nothing for the relay to push to.
async listPushSubscriptions() { return []; }
async createPushSubscription(): Promise<string> {
throw new Error('Push subscriptions are not available in demo mode');
}
async verifyPushSubscription(): Promise<void> {
throw new Error('Push subscriptions are not available in demo mode');
}
async updatePushSubscription(): Promise<boolean> { return false; }
async destroyPushSubscription(): Promise<void> { /* no-op */ }
// ── Quota ─────────────────────────────────────────────────────
async getQuota(): Promise<{ used: number; total: number } | null> {
@@ -383,6 +395,7 @@ export class DemoJMAPClient implements IJMAPClient {
draftId?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
_fromName?: string,
htmlBody?: string,
): Promise<string> {
const draftsMb = this.data.mailboxes.find(m => m.role === 'drafts');
const id = draftId || generateDemoId('email');
@@ -402,9 +415,13 @@ export class DemoJMAPClient implements IJMAPClient {
sentAt: new Date().toISOString(),
preview: body.substring(0, 200),
hasAttachment: !!attachments?.length,
textBody: [{ partId: '1', blobId: generateDemoId('blob'), size: body.length, type: 'text/plain' }],
htmlBody: [],
bodyValues: { '1': { value: body } },
textBody: [{ partId: htmlBody ? 'text' : '1', blobId: generateDemoId('blob'), size: body.length, type: 'text/plain' }],
htmlBody: htmlBody
? [{ partId: 'html', blobId: generateDemoId('blob'), size: htmlBody.length, type: 'text/html' }]
: [],
bodyValues: htmlBody
? { text: { value: body }, html: { value: htmlBody } }
: { '1': { value: body } },
attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })),
messageId: `<${id}@demo.example.com>`,
};
@@ -430,6 +447,8 @@ export class DemoJMAPClient implements IJMAPClient {
_fromName?: string,
htmlBody?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[],
references?: string[],
): Promise<void> {
// Remove draft if updating
if (draftId) {
@@ -455,6 +474,8 @@ export class DemoJMAPClient implements IJMAPClient {
bodyValues: htmlBody ? { '1': { value: body }, '2': { value: htmlBody } } : { '1': { value: body } },
attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })),
messageId: `<${generateDemoId('msg')}@demo.example.com>`,
inReplyTo: inReplyTo?.length ? inReplyTo : undefined,
references: references?.length ? references : undefined,
};
this.data.emails.push(email);
this.recalcMailboxCounts();
+1
View File
@@ -151,6 +151,7 @@ export function collapseBlockedImageContainers(html: string): string {
const hasVisibleMedia = el.querySelector('img:not([data-blocked-src]), video, canvas');
const hasLinks = el.querySelector('a[href]');
if (!hasVisibleText && !hasVisibleMedia && !hasLinks) {
el.setAttribute('data-blocked-collapsed-style', el.style.cssText);
el.style.display = 'none';
el.style.height = '0';
el.style.padding = '0';
+51
View File
@@ -0,0 +1,51 @@
/**
* RFC 5322 §3.6.4 reply threading.
*
* Computes the In-Reply-To and References headers an outgoing reply must
* carry so MUAs can stitch the conversation back together.
*
* In-Reply-To = parent.Message-ID
* References = parent.References (if any) + parent.Message-ID
*
* Bare msg-ids only angle brackets are stripped because JMAP RFC 8621
* §4.1.2.3 stores Message-IDs without them.
*/
export interface ParentThreadingInfo {
// JMAP RFC 8621 §4.1.2.3 specifies messageId as String[]|null, but the
// codebase has historically typed it as string. Accept either shape.
messageId?: string | string[];
references?: string[];
}
export interface ReplyThreadingHeaders {
inReplyTo: string[];
references: string[];
}
export function stripMessageIdBrackets(id: string): string {
return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim();
}
export function computeReplyThreadingHeaders(
parent: ParentThreadingInfo | undefined,
): ReplyThreadingHeaders | null {
const rawId = Array.isArray(parent?.messageId) ? parent.messageId[0] : parent?.messageId;
const parentId = rawId ? stripMessageIdBrackets(rawId) : '';
if (!parentId) return null;
const ancestors = (parent?.references ?? [])
.map(stripMessageIdBrackets)
.filter(Boolean);
// De-dupe while preserving order; the parent's id closes the chain.
const seen = new Set<string>();
const references: string[] = [];
for (const id of [...ancestors, parentId]) {
if (seen.has(id)) continue;
seen.add(id);
references.push(id);
}
return { inReplyTo: [parentId], references };
}
+18 -1
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 } from "./types";
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 { SieveScript, SieveCapabilities } from "./sieve-types";
/**
@@ -51,6 +51,20 @@ export interface IJMAPClient {
getLastStates(): AccountStates;
setLastStates(states: AccountStates): void;
// ── PushSubscription (RFC 8620 §7.2) ───────────────────────────
// Browser-driven Web Push setup: register a relay URL the JMAP server can
// forward StateChange events to. Mobile uses the same primitives.
listPushSubscriptions(): Promise<PushSubscription[]>;
createPushSubscription(params: {
deviceClientId: string;
url: string;
types: string[];
expires?: string;
}): Promise<string>;
verifyPushSubscription(id: string, verificationCode: string): Promise<void>;
updatePushSubscription(id: string, patch: { expires?: string; types?: string[] }): Promise<boolean>;
destroyPushSubscription(id: string): Promise<void>;
// ── Quota ─────────────────────────────────────────────────────
getQuota(): Promise<{ used: number; total: number } | null>;
@@ -115,6 +129,7 @@ export interface IJMAPClient {
draftId?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
fromName?: string,
htmlBody?: string,
): Promise<string>;
sendEmail(
@@ -129,6 +144,8 @@ export interface IJMAPClient {
fromName?: string,
htmlBody?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[],
references?: string[],
): Promise<void>;
sendImipReply(opts: {
+104 -6
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 } from "./types";
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 { SieveScript, SieveCapabilities } from "./sieve-types";
import type { IJMAPClient } from "./client-interface";
import { toWildcardQuery } from "./search-utils";
@@ -294,6 +294,12 @@ function foldIcsLine(line: string): string {
return chunks.join('\r\n');
}
// JMAP RFC 8621 stores Message-IDs without angle brackets. Strip any that
// snuck in (e.g. when echoing values that originated from RFC 5322 headers).
function stripMessageIdBrackets(id: string): string {
return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim();
}
export class JMAPClient implements IJMAPClient {
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000;
@@ -1933,7 +1939,8 @@ export class JMAPClient implements IJMAPClient {
fromEmail?: string,
draftId?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
fromName?: string
fromName?: string,
htmlBody?: string
): Promise<string> {
const mailboxes = await this.getMailboxes();
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
@@ -1952,7 +1959,8 @@ export class JMAPClient implements IJMAPClient {
keywords: Record<string, boolean>;
mailboxIds: Record<string, boolean>;
bodyValues: Record<string, { value: string }>;
textBody: { partId: string }[];
textBody: { partId: string; type?: string }[];
htmlBody?: { partId: string; type: string }[];
attachments?: { blobId: string; type: string; name: string; disposition: string; cid?: string }[];
}
@@ -1964,8 +1972,13 @@ export class JMAPClient implements IJMAPClient {
subject,
keywords: { "$draft": true },
mailboxIds: { [draftsMailbox.id]: true },
bodyValues: { "1": { value: body } },
textBody: [{ partId: "1" }],
bodyValues: htmlBody
? { "text": { value: body }, "html": { value: htmlBody } }
: { "1": { value: body } },
textBody: htmlBody
? [{ partId: "text", type: "text/plain" }]
: [{ partId: "1" }],
...(htmlBody ? { htmlBody: [{ partId: "html", type: "text/html" }] } : {}),
};
if (attachments?.length) {
@@ -2027,7 +2040,9 @@ export class JMAPClient implements IJMAPClient {
draftId?: string,
fromName?: string,
htmlBody?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[],
references?: string[]
): Promise<void> {
const emailId = `send-${Date.now()}`;
const mailboxes = await this.getMailboxes();
@@ -2067,6 +2082,11 @@ export class JMAPClient implements IJMAPClient {
}
}
// Per RFC 8621 §4.1.2.3 inReplyTo/references are arrays of bare msg-ids
// (no angle brackets). Stalwart may return them either way, so normalize.
const normalizedInReplyTo = inReplyTo?.map(stripMessageIdBrackets).filter(Boolean);
const normalizedReferences = references?.map(stripMessageIdBrackets).filter(Boolean);
// Always create a new email with the final body content
const emailCreate: Record<string, unknown> = {
from: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }],
@@ -2075,6 +2095,8 @@ export class JMAPClient implements IJMAPClient {
cc: cc?.map(email => ({ email })),
bcc: bcc?.map(email => ({ email })),
subject,
inReplyTo: normalizedInReplyTo?.length ? normalizedInReplyTo : undefined,
references: normalizedReferences?.length ? normalizedReferences : undefined,
keywords: { "$seen": true, "$draft": true },
mailboxIds: { [draftsMailbox.id]: true },
};
@@ -5291,4 +5313,80 @@ export class JMAPClient implements IJMAPClient {
}
}
}
// ── PushSubscription (RFC 8620 §7.2) ──────────────────────────────
// Used by the PWA Web Push integration. The mobile app does the same dance
// through its own JMAP client - keep these in sync.
async listPushSubscriptions(): Promise<PushSubscription[]> {
const response = await this.request(
[['PushSubscription/get', { ids: null }, '0']],
['urn:ietf:params:jmap:core'],
);
const [, body] = response.methodResponses[0] ?? [];
return ((body as { list?: PushSubscription[] } | undefined)?.list) ?? [];
}
async createPushSubscription(params: {
deviceClientId: string;
url: string;
types: string[];
expires?: string;
}): Promise<string> {
const created: Record<string, unknown> = {
deviceClientId: params.deviceClientId,
url: params.url,
types: params.types,
};
if (params.expires) created.expires = params.expires;
const response = await this.request(
[['PushSubscription/set', { create: { new: created } }, '0']],
['urn:ietf:params:jmap:core'],
);
const [, body] = response.methodResponses[0] ?? [];
const result = (body as { created?: { new?: { id?: string } }; notCreated?: { new?: unknown } } | undefined);
const id = result?.created?.new?.id;
if (!id) {
throw new Error(
`PushSubscription/set create failed: ${JSON.stringify(result?.notCreated?.new ?? body)}`,
);
}
return id;
}
async verifyPushSubscription(id: string, verificationCode: string): Promise<void> {
const response = await this.request(
[['PushSubscription/set', { update: { [id]: { verificationCode } } }, '0']],
['urn:ietf:params:jmap:core'],
);
const [, body] = response.methodResponses[0] ?? [];
const notUpdated = (body as { notUpdated?: Record<string, unknown> } | undefined)?.notUpdated?.[id];
if (notUpdated) {
throw new Error(`PushSubscription verification failed: ${JSON.stringify(notUpdated)}`);
}
}
// Returns false when the server rejects the update (e.g. the subscription
// was already destroyed) - the caller treats that as a signal to recreate.
async updatePushSubscription(
id: string,
patch: { expires?: string; types?: string[] },
): Promise<boolean> {
const response = await this.request(
[['PushSubscription/set', { update: { [id]: patch } }, '0']],
['urn:ietf:params:jmap:core'],
);
const [, body] = response.methodResponses[0] ?? [];
const r = body as { updated?: Record<string, unknown>; notUpdated?: Record<string, unknown> } | undefined;
if (r?.notUpdated?.[id]) return false;
return r?.updated?.[id] !== undefined;
}
async destroyPushSubscription(id: string): Promise<void> {
await this.request(
[['PushSubscription/set', { destroy: [id] }, '0']],
['urn:ietf:params:jmap:core'],
);
}
}
+45 -14
View File
@@ -1,12 +1,32 @@
/**
* Sub-addressing utilities for user+tag@domain.com format
* Sub-addressing utilities for user{delimiter}tag@domain.com format
* Works server-side automatically - no JMAP API calls needed
*
* The delimiter character is configurable per server (RFC 5233). Common
* choices: "+" (Postfix, Stalwart default), "-" (qmail), ".", "=".
*/
// Constants for tag validation
const MAX_TAG_LENGTH = 30;
const TAG_REGEX = /^[a-zA-Z0-9-]{1,30}$/;
export const DEFAULT_SUB_ADDRESS_DELIMITER = '+';
export const SUPPORTED_SUB_ADDRESS_DELIMITERS = ['+', '-', '.', '='] as const;
export type SubAddressDelimiterPreset = (typeof SUPPORTED_SUB_ADDRESS_DELIMITERS)[number];
export function isSupportedSubAddressDelimiter(value: string): value is SubAddressDelimiterPreset {
return (SUPPORTED_SUB_ADDRESS_DELIMITERS as readonly string[]).includes(value);
}
// RFC 5321 atext "special" characters, minus alphanumerics and "@". A custom
// delimiter must be exactly one of these — they're safe to embed in a local
// part and unambiguously separate the user from the tag.
const VALID_DELIMITER_REGEX = /^[!#$%&'*+\-./=?^_`{|}~]$/;
export function isValidSubAddressDelimiter(value: unknown): value is string {
return typeof value === 'string' && VALID_DELIMITER_REGEX.test(value);
}
export type TagValidationErrorCode =
| 'EMPTY'
| 'TOO_LONG'
@@ -22,10 +42,14 @@ export interface ParsedAddress {
}
/**
* Parse an email address to extract sub-address tag
* Example: "user+shopping@example.com" -> { baseUser: "user", tag: "shopping" }
* Parse an email address to extract sub-address tag.
* The first occurrence of the delimiter in the local part separates the
* base user from the tag, matching the behavior of Postfix/qmail/Sieve.
*/
export function parseSubAddress(email: string): ParsedAddress {
export function parseSubAddress(
email: string,
delimiter: string = DEFAULT_SUB_ADDRESS_DELIMITER,
): ParsedAddress {
const [localPart, domain] = email.split('@');
if (!localPart || !domain) {
@@ -38,9 +62,9 @@ export function parseSubAddress(email: string): ParsedAddress {
};
}
const plusIndex = localPart.indexOf('+');
const delimiterIndex = localPart.indexOf(delimiter);
if (plusIndex === -1) {
if (delimiterIndex === -1) {
return {
localPart,
baseUser: localPart,
@@ -50,8 +74,8 @@ export function parseSubAddress(email: string): ParsedAddress {
};
}
const baseUser = localPart.substring(0, plusIndex);
const tag = localPart.substring(plusIndex + 1);
const baseUser = localPart.substring(0, delimiterIndex);
const tag = localPart.substring(delimiterIndex + delimiter.length);
return {
localPart,
@@ -63,18 +87,25 @@ export function parseSubAddress(email: string): ParsedAddress {
}
/**
* Generate a sub-addressed email
* Example: generateSubAddress("user@example.com", "shopping") -> "user+shopping@example.com"
* Generate a sub-addressed email.
* Example: generateSubAddress("user@example.com", "shopping", "+") -> "user+shopping@example.com"
*/
export function generateSubAddress(baseEmail: string, tag: string): string {
export function generateSubAddress(
baseEmail: string,
tag: string,
delimiter: string = DEFAULT_SUB_ADDRESS_DELIMITER,
): string {
const [localPart, domain] = baseEmail.split('@');
if (!localPart || !domain || !tag) {
return baseEmail;
}
// Remove existing tag if present
const cleanLocal = localPart.split('+')[0];
// Strip an existing tag if one is already present
const existingDelimiterIndex = localPart.indexOf(delimiter);
const cleanLocal = existingDelimiterIndex === -1
? localPart
: localPart.substring(0, existingDelimiterIndex);
// Sanitize tag (alphanumeric and dash only)
const cleanTag = tag.replace(/[^a-zA-Z0-9-]/g, '').toLowerCase();
@@ -83,7 +114,7 @@ export function generateSubAddress(baseEmail: string, tag: string): string {
return baseEmail;
}
return `${cleanLocal}+${cleanTag}@${domain}`;
return `${cleanLocal}${delimiter}${cleanTag}@${domain}`;
}
/**
+352
View File
@@ -0,0 +1,352 @@
// Browser-side Web Push setup. Mirrors the React Native flow in
// repos/react-native/src/lib/push-notifications.ts so the relay sees the same
// shape from both clients - the only differences are which native API
// produces the push token (PushManager.subscribe here, FCM there) and which
// register endpoint we hit on the relay.
import type { IJMAPClient } from '@/lib/jmap/client-interface';
const DEVICE_CLIENT_ID_KEY = 'bulwark.push.deviceClientId.v1';
const SUBSCRIPTION_ID_KEY = 'bulwark.push.subscriptionId.v1';
// Hosted relay so self-hosters don't need their own VAPID + Firebase setup.
// Override at build time via NEXT_PUBLIC_PUSH_RELAY_URL or at runtime by
// calling enableWebPush({ relayBaseUrl }) from the settings UI.
export const DEFAULT_RELAY_BASE_URL =
process.env.NEXT_PUBLIC_PUSH_RELAY_URL || 'https://notifications.relay.bulwarkmail.org';
// Match the mobile app's lifetime hint. The JMAP server may clamp this down.
const SUBSCRIPTION_EXPIRES_DAYS = 90;
const SUBSCRIPTION_REFRESH_THRESHOLD_DAYS = 7;
// Only `EmailDelivery` state-changes when new mail is actually delivered.
// `Email` fires for any mutation (sending, drafting, moving, marking read,
// deleting) and `Mailbox` fires for mailbox edits — both produced spurious
// system notifications, so we keep them out of the push subscription.
// In-app sync uses a separate StateChange channel and is unaffected.
const PUSH_TYPES = ['EmailDelivery'] as const;
function sameTypes(a: readonly string[] | null | undefined, b: readonly string[]): boolean {
if (!a || a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((t, i) => t === sortedB[i]);
}
export interface EnableWebPushParams {
client: IJMAPClient;
// Optional - falls back to DEFAULT_RELAY_BASE_URL.
relayBaseUrl?: string;
// Free-form label the relay shows in /metrics; never returned in pushes.
accountLabel?: string;
}
export interface EnableWebPushResult {
subscriptionId: string;
}
export class WebPushUnsupportedError extends Error {
constructor(message: string) {
super(message);
this.name = 'WebPushUnsupportedError';
}
}
export function isWebPushSupported(): boolean {
if (typeof window === 'undefined') return false;
return (
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
);
}
function buildRelayUrl(base: string, suffix: string): string {
return base.replace(/\/+$/, '') + suffix;
}
function expiresFromNow(days: number): string {
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
}
function randomDeviceClientId(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
function getOrCreateDeviceClientId(): string {
const existing = localStorage.getItem(DEVICE_CLIENT_ID_KEY);
if (existing) return existing;
const next = randomDeviceClientId();
localStorage.setItem(DEVICE_CLIENT_ID_KEY, next);
return next;
}
// PushManager.subscribe wants the VAPID public key as a BufferSource.
// Returning a Uint8Array<ArrayBuffer> (not the wider ArrayBufferLike that
// includes SharedArrayBuffer) keeps strict TS happy on lib.dom 2024+.
function urlBase64ToUint8Array(base64Url: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64Url.length % 4)) % 4);
const base64 = (base64Url + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
const buffer = new ArrayBuffer(raw.length);
const out = new Uint8Array(buffer);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
function readPushKey(
sub: PushSubscription,
name: 'p256dh' | 'auth',
): string {
const raw = sub.getKey(name);
if (!raw) throw new Error(`PushSubscription is missing the ${name} key`);
// Browsers want application/json over the wire so encode as base64url.
let binary = '';
const bytes = new Uint8Array(raw);
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function fetchVapidPublicKey(relayBaseUrl: string): Promise<string> {
const res = await fetch(buildRelayUrl(relayBaseUrl, '/api/push/vapid-public-key'));
if (!res.ok) {
if (res.status === 503) {
throw new Error('The push relay does not have Web Push configured');
}
throw new Error(`Failed to fetch VAPID key: ${res.status}`);
}
const body = (await res.json()) as { publicKey?: string };
if (!body.publicKey) throw new Error('Relay returned an empty VAPID key');
return body.publicKey;
}
async function ensurePermission(): Promise<void> {
if (Notification.permission === 'granted') return;
if (Notification.permission === 'denied') {
throw new Error('Notifications are blocked - allow them in browser settings to continue');
}
const result = await Notification.requestPermission();
if (result !== 'granted') {
throw new Error('Notification permission was not granted');
}
}
async function ensureServiceWorker(): Promise<ServiceWorkerRegistration> {
// The webmail's PWA already registers /sw.js for installability. If it
// hasn't been picked up yet (e.g. first load), kick it ourselves so the
// push handler is in place.
let registration = await navigator.serviceWorker.getRegistration('/');
if (!registration) {
registration = await navigator.serviceWorker.register('/sw.js');
}
await navigator.serviceWorker.ready;
return registration;
}
async function registerWithRelay(params: {
relayBaseUrl: string;
subscriptionId: string;
// Subset of PushSubscriptionJSON we actually serialise. Inlined so eslint's
// no-undef rule (which doesn't know about DOM type-only globals) is happy.
subscription: {
endpoint: string;
keys: { p256dh: string; auth: string };
};
accountLabel?: string;
}): Promise<void> {
const { endpoint, keys } = params.subscription;
if (!endpoint || !keys?.p256dh || !keys?.auth) {
throw new Error('Browser returned an incomplete PushSubscription');
}
const res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register/web'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
subscriptionId: params.subscriptionId,
subscription: { endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } },
accountLabel: params.accountLabel,
}),
});
if (!res.ok) {
throw new Error(`Relay register failed: ${res.status}`);
}
}
async function pollVerificationCode(
relayBaseUrl: string,
subscriptionId: string,
): Promise<string> {
// Stalwart per-account rate-limits PushVerification posts (default 60s).
// If there are leftover unverified subscriptions on the account, our new
// one queues up behind them - so we wait long enough to clear one verify
// window even in the unlucky case.
const timeoutAt = Date.now() + 75_000;
let delay = 400;
while (Date.now() < timeoutAt) {
const res = await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`),
);
if (res.ok) {
const body = (await res.json()) as { verificationCode?: string | null };
if (body.verificationCode) return body.verificationCode;
}
await new Promise((r) => setTimeout(r, delay));
delay = Math.min(delay * 1.5, 2000);
}
throw new Error('Timed out waiting for PushVerification from the JMAP server');
}
async function refreshSubscriptionExpires(
client: IJMAPClient,
sub: { id: string; expires: string | null; types: string[] | null },
): Promise<boolean> {
const typesNeedUpdate = !sameTypes(sub.types, PUSH_TYPES);
if (!typesNeedUpdate && sub.expires) {
const remainingMs = new Date(sub.expires).getTime() - Date.now();
const thresholdMs = SUBSCRIPTION_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000;
if (Number.isFinite(remainingMs) && remainingMs > thresholdMs) return true;
}
try {
const patch: { expires?: string; types?: string[] } = {
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
};
if (typesNeedUpdate) patch.types = [...PUSH_TYPES];
return await client.updatePushSubscription(sub.id, patch);
} catch {
return false;
}
}
export async function enableWebPush(
params: EnableWebPushParams,
): Promise<EnableWebPushResult> {
if (!isWebPushSupported()) {
throw new WebPushUnsupportedError(
'This browser does not support Web Push. On iOS the site needs to be installed to the home screen.',
);
}
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
if (!relayBaseUrl) throw new Error('relayBaseUrl is required');
await ensurePermission();
const registration = await ensureServiceWorker();
const vapidPublicKey = await fetchVapidPublicKey(relayBaseUrl);
// Reuse an existing browser PushSubscription when possible - resubscribing
// with the same VAPID key produces the same endpoint, but the call still
// costs a network round-trip the user can feel.
let pushSubscription = await registration.pushManager.getSubscription();
if (pushSubscription) {
const keyMatches = pushSubscription.options?.applicationServerKey;
if (!keyMatches) {
await pushSubscription.unsubscribe();
pushSubscription = null;
}
}
if (!pushSubscription) {
pushSubscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
}
const deviceClientId = getOrCreateDeviceClientId();
await registerWithRelay({
relayBaseUrl,
subscriptionId: deviceClientId,
subscription: {
endpoint: pushSubscription.endpoint,
keys: {
p256dh: readPushKey(pushSubscription, 'p256dh'),
auth: readPushKey(pushSubscription, 'auth'),
},
},
accountLabel: params.accountLabel,
});
// Reuse the JMAP-side PushSubscription if the server still has it, just
// refreshing the expiry so it doesn't time out between sessions.
const existingSubs = await params.client.listPushSubscriptions().catch(() => []);
const storedServerId = localStorage.getItem(SUBSCRIPTION_ID_KEY);
if (storedServerId) {
const match = existingSubs.find((s) => s.id === storedServerId);
if (match) {
const refreshed = await refreshSubscriptionExpires(params.client, match);
if (refreshed) return { subscriptionId: storedServerId };
await params.client.destroyPushSubscription(storedServerId).catch(() => undefined);
}
localStorage.removeItem(SUBSCRIPTION_ID_KEY);
}
// Reap any leftover subscriptions still bound to this device. These pile
// up when a previous enable attempt failed mid-flow (verification timed
// out, browser tab closed, etc). Stalwart per-account rate-limits
// verification posts, so leaving stragglers around blocks the new one.
const stragglers = existingSubs.filter(
(s) => s.deviceClientId === deviceClientId && s.id !== storedServerId,
);
for (const s of stragglers) {
await params.client.destroyPushSubscription(s.id).catch(() => undefined);
}
const serverAssignedId = await params.client.createPushSubscription({
deviceClientId,
url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`),
types: [...PUSH_TYPES],
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
});
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
await params.client.verifyPushSubscription(serverAssignedId, verificationCode);
localStorage.setItem(SUBSCRIPTION_ID_KEY, serverAssignedId);
return { subscriptionId: serverAssignedId };
}
export interface DisableWebPushParams {
client: IJMAPClient;
relayBaseUrl?: string;
}
// Best-effort teardown: clear the JMAP subscription, the relay mapping, and
// the browser PushSubscription. Any single failure is swallowed so the user
// always ends up in a "disabled" state locally.
export async function disableWebPush(params: DisableWebPushParams): Promise<void> {
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
const storedServerId = localStorage.getItem(SUBSCRIPTION_ID_KEY);
if (storedServerId) {
await params.client.destroyPushSubscription(storedServerId).catch(() => undefined);
localStorage.removeItem(SUBSCRIPTION_ID_KEY);
}
const deviceClientId = localStorage.getItem(DEVICE_CLIENT_ID_KEY);
if (deviceClientId && relayBaseUrl) {
await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
{ method: 'DELETE' },
).catch(() => undefined);
}
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.getRegistration('/');
const sub = await registration?.pushManager.getSubscription();
if (sub) await sub.unsubscribe().catch(() => undefined);
}
}
export async function isWebPushEnabled(): Promise<boolean> {
if (!isWebPushSupported()) return false;
if (Notification.permission !== 'granted') return false;
const registration = await navigator.serviceWorker.getRegistration('/');
if (!registration) return false;
const sub = await registration.pushManager.getSubscription();
return sub !== null && localStorage.getItem(SUBSCRIPTION_ID_KEY) !== null;
}
+53 -9
View File
@@ -127,6 +127,7 @@
"demo_tour": "Průvodce",
"tags": "Štítky",
"folders": "Složky",
"shared": "Sdílené",
"mail": "Pošta",
"nav_label": "Navigace",
"add_app": "Aplikace"
@@ -426,8 +427,8 @@
"event_updated": "Aktualizace #{sequence}",
"event_status_tentative": "Nezávazně",
"event_status_cancelled": "Zrušeno",
"expand": "Zobrazit detaily",
"collapse": "Skrýt detaily"
"expand": "Zobrazit podrobnosti",
"collapse": "Skrýt podrobnosti"
},
"send": "Odeslat",
"more": "více"
@@ -509,7 +510,10 @@
"message": "Vaše zpráva obsahuje slovo \"{keyword}\", ale není k ní připojen žádný soubor. Přesto odeslat?",
"send_anyway": "Přesto odeslat",
"back": "Zpět k úpravám"
}
},
"add_link": "Přidat odkaz",
"link_url_prompt": "Zadejte URL",
"sending": "Odesílání..."
},
"confirm_dialog": {
"confirm": "Potvrdit",
@@ -602,7 +606,6 @@
},
"language": {
"title": "Jazyk",
"czech": "Česky",
"english": "English",
"french": "Français",
"japanese": "日本語",
@@ -614,7 +617,6 @@
"portuguese": "Português",
"russian": "Русский",
"select_language": "Vybrat jazyk",
"switch_to_czech": "Přepnout na češtinu",
"switch_to_english": "Přepnout na angličtinu",
"switch_to_french": "Přepnout na francouzštinu",
"switch_to_japanese": "Přepnout na japonštinu",
@@ -804,6 +806,23 @@
"sound_desc": "Přehrát zvukové upozornění pro připomenutí kalendáře",
"invitation_parsing": "Rozpoznávat e-mailové pozvánky",
"invitation_parsing_desc": "Rozpoznávat pozvánky kalendáře v přílohách e-mailů a zobrazovat akce kalendáře"
},
"push": {
"confirm_disable_message": "Toto zařízení přestane přijímat upozornění, když je web zavřený.",
"confirm_disable_title": "Zakázat oznámení na pozadí?",
"description": "Přijímat systémová oznámení o nové poště, když je tento web zavřený. Doručováno přes push relay Bulwark; relay nikdy nevidí obsah pošty.",
"disable": "Zakázat",
"enable": "Povolit",
"ios_hint": "Na iOS nejprve nainstalujte web na domovskou obrazovku Safari doručuje Web Push pouze nainstalovaným PWA.",
"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_placeholder": "https://notifications.relay.example.com",
"status_active": "Aktivní na tomto zařízení",
"status_busy": "Pracuji…",
"status_inactive": "Není povoleno na tomto zařízení",
"status_unsupported": "Tento prohlížeč nepodporuje Web Push",
"title": "Oznámení na pozadí"
}
},
"language_region": {
@@ -892,6 +911,13 @@
"label": "Automaticky vybírat adresu pro odpověď",
"description": "Při odpovídání automaticky přepnout adresu odesílatele na identitu, která původně obdržela zprávu"
},
"sub_address_delimiter": {
"label": "Oddělovač sub-adresy",
"description": "Znak oddělující uživatelské jméno od sub-adresy. Zvolte oddělovač používaný vaším poštovním serverem (např. uzivatel{delimiter}stitek@domena.cz).",
"option": "{delimiter} (uzivatel{delimiter}stitek@domena.cz)",
"custom": "Vlastní…",
"custom_input_label": "Vlastní znak oddělovače"
},
"attachment_click_action": {
"label": "Akce po kliknutí na přílohu",
"description": "Vyberte, zda má kliknutí na přílohu zobrazit náhled, nebo ji ihned stáhnout",
@@ -1327,8 +1353,8 @@
"contacts": {
"title": "Kontakty",
"description": "Import a export kontaktů",
"group_by_letter_label": "Seskupit podle prvního písmene",
"group_by_letter_description": "Zobrazovat abecední nadpisy v seznamu kontaktů",
"group_by_letter_label": "Seskupit podle prvního písmena",
"group_by_letter_description": "Zobrazit záhlaví sekcí podle abecedy v seznamu kontaktů",
"import_label": "Importovat kontakty",
"import_description": "Importovat kontakty ze souboru vCard (.vcf)",
"export_label": "Exportovat kontakty",
@@ -1737,7 +1763,7 @@
"use_address": "Použít tuto adresu",
"invalid_tag": "Štítek může obsahovat pouze písmena, číslice a pomlčky",
"tag_too_long": "Štítek může mít maximálně 30 znaků",
"help_text": "Zprávy odeslané na adresu uzivatel+stitek@domena.cz budou doručeny do vaší doručené pošty",
"help_text": "Zprávy odeslané na adresu uzivatel{delimiter}stitek@domena.cz budou doručeny do vaší doručené pošty",
"validation": {
"empty": "Štítek nesmí být prázdný",
"too_long": "Štítek může mít maximálně {max} znaků",
@@ -2134,7 +2160,11 @@
"tomorrow_header": "Zítra",
"export_ics": "Exportovat jako .ics",
"copy_title": "Kopírovat název",
"copy_link": "Kopírovat odkaz na schůzku"
"copy_link": "Kopírovat odkaz na schůzku",
"go_to_today": "Přejít na dnešek",
"new_all_day_event": "Nová celodenní událost",
"new_event": "Nová událost",
"new_task": "Nový úkol"
},
"detail": {
"add_note": "Přidat poznámku...",
@@ -2433,6 +2463,20 @@
"due_today": "Dnes",
"due_tomorrow": "Zítra",
"overdue": "Po termínu"
},
"months": {
"jan": "led",
"feb": "úno",
"mar": "bře",
"apr": "dub",
"may": "kvě",
"jun": "čvn",
"jul": "čvc",
"aug": "srp",
"sep": "zář",
"oct": "říj",
"nov": "lis",
"dec": "pro"
}
},
"advanced_search": {
+54 -5
View File
@@ -424,7 +424,9 @@
"cancel_info": "Der Organisator hat diesen Termin abgesagt.",
"event_updated": "Aktualisierung #{sequence}",
"event_status_tentative": "Vorläufig",
"event_status_cancelled": "Abgesagt"
"event_status_cancelled": "Abgesagt",
"collapse": "Details ausblenden",
"expand": "Details anzeigen"
},
"previous": "Zurück",
"next": "Weiter",
@@ -508,7 +510,10 @@
"message": "Ihre Nachricht enthält \"{keyword}\", aber es ist keine Datei angehängt. Trotzdem senden?",
"send_anyway": "Trotzdem senden",
"back": "Zurück zur Bearbeitung"
}
},
"add_link": "Link hinzufügen",
"link_url_prompt": "URL eingeben",
"sending": "Wird gesendet..."
},
"confirm_dialog": {
"confirm": "Bestätigen",
@@ -801,6 +806,23 @@
"sound_desc": "Einen Ton für Kalendererinnerungen abspielen",
"invitation_parsing": "E-Mail-Einladungen erkennen",
"invitation_parsing_desc": "Kalendereinladungen in E-Mail-Anhängen erkennen und Kalenderaktionen anzeigen"
},
"push": {
"confirm_disable_message": "Dieses Gerät erhält keine Benachrichtigungen mehr, wenn die Seite geschlossen ist.",
"confirm_disable_title": "Hintergrundbenachrichtigungen deaktivieren?",
"description": "Systembenachrichtigungen für neue E-Mails empfangen, wenn diese Seite geschlossen ist. Zustellung über das Bulwark Push-Relay; das Relay sieht niemals E-Mail-Inhalte.",
"disable": "Deaktivieren",
"enable": "Aktivieren",
"ios_hint": "Installieren Sie die Seite unter iOS zuerst auf dem Startbildschirm Safari liefert Web Push nur an installierte PWAs.",
"reenable": "Neu registrieren",
"relay_desc": "Standardmäßig wird das gehostete Bulwark-Relay verwendet. Nur ändern, wenn Sie selbst hosten.",
"relay_label": "Push-Relay",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Auf diesem Gerät aktiv",
"status_busy": "Wird verarbeitet…",
"status_inactive": "Auf diesem Gerät nicht aktiviert",
"status_unsupported": "Dieser Browser unterstützt Web Push nicht",
"title": "Hintergrundbenachrichtigungen"
}
},
"language_region": {
@@ -889,6 +911,13 @@
"label": "Antwortadresse automatisch wählen",
"description": "Beim Antworten die Absenderadresse automatisch auf die Identität umstellen, die die ursprüngliche Nachricht erhalten hat"
},
"sub_address_delimiter": {
"label": "Sub-Adress-Trennzeichen",
"description": "Zeichen, das Ihren Benutzernamen vom Sub-Adress-Tag trennt. Verwenden Sie das von Ihrem Mailserver verwendete Trennzeichen (z. B. benutzer{delimiter}tag@domain.de).",
"option": "{delimiter} (benutzer{delimiter}tag@domain.de)",
"custom": "Benutzerdefiniert…",
"custom_input_label": "Benutzerdefiniertes Trennzeichen"
},
"attachment_click_action": {
"label": "Aktion beim Klick auf Anhänge",
"description": "Festlegen, ob ein Dateianhang beim Anklicken in der Vorschau geöffnet oder sofort heruntergeladen wird",
@@ -1333,7 +1362,9 @@
"no_address_books": "Keine Adressbücher gefunden",
"categories_title": "Kategorien",
"categories_description": "Kontaktkategorien umbenennen",
"no_categories": "Keine Kategorien gefunden"
"no_categories": "Keine Kategorien gefunden",
"group_by_letter_description": "Alphabetische Abschnittsüberschriften in der Kontaktliste anzeigen",
"group_by_letter_label": "Nach Anfangsbuchstaben gruppieren"
},
"filters": {
"title": "E-Mail-Filter",
@@ -1732,7 +1763,7 @@
"use_address": "Diese Adresse verwenden",
"invalid_tag": "Tag darf nur alphanumerisch und Bindestriche enthalten",
"tag_too_long": "Tag darf maximal 30 Zeichen lang sein",
"help_text": "E-Mails an benutzer+tag@domain.de werden in Ihrem Posteingang ankommen",
"help_text": "E-Mails an benutzer{delimiter}tag@domain.de werden in Ihrem Posteingang ankommen",
"validation": {
"empty": "Tag darf nicht leer sein",
"too_long": "Tag darf maximal {max} Zeichen lang sein",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "Morgen",
"export_ics": "Als .ics exportieren",
"copy_title": "Titel kopieren",
"copy_link": "Meeting-Link kopieren"
"copy_link": "Meeting-Link kopieren",
"go_to_today": "Zu heute",
"new_all_day_event": "Neuer ganztägiger Termin",
"new_event": "Neuer Termin",
"new_task": "Neue Aufgabe"
},
"detail": {
"add_note": "Notiz hinzufügen...",
@@ -2428,6 +2463,20 @@
"due_today": "Heute fällig",
"due_tomorrow": "Morgen fällig",
"overdue": "Überfällig"
},
"months": {
"jan": "Jan",
"feb": "Feb",
"mar": "Mär",
"apr": "Apr",
"may": "Mai",
"jun": "Jun",
"jul": "Jul",
"aug": "Aug",
"sep": "Sep",
"oct": "Okt",
"nov": "Nov",
"dec": "Dez"
}
},
"advanced_search": {
+39 -1
View File
@@ -783,6 +783,23 @@
"swift": "Swift Gesture",
"relax": "Relax"
},
"push": {
"title": "Background Notifications",
"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_placeholder": "https://notifications.relay.example.com",
"status_active": "Active on this device",
"status_inactive": "Not enabled on this device",
"status_unsupported": "This browser does not support Web Push",
"status_busy": "Working…",
"enable": "Enable",
"reenable": "Re-register",
"disable": "Disable",
"confirm_disable_title": "Disable background notifications?",
"confirm_disable_message": "This device will stop receiving alerts when the site is closed.",
"ios_hint": "On iOS, install the site to your home screen first - Safari only delivers Web Push to installed PWAs."
},
"sound_selection": {
"title": "Notification Sound",
"description": "Choose which sound to play for notifications",
@@ -894,6 +911,13 @@
"label": "Auto-select Reply Address",
"description": "When replying, automatically switch the From address to the identity that originally received the message"
},
"sub_address_delimiter": {
"label": "Sub-Address Delimiter",
"description": "Character separating your username from a sub-address tag. Match the delimiter your mail server uses (e.g. user{delimiter}tag@domain.com).",
"option": "{delimiter} (user{delimiter}tag@domain.com)",
"custom": "Custom…",
"custom_input_label": "Custom delimiter character"
},
"attachment_click_action": {
"label": "Attachment Click Action",
"description": "Choose whether clicking a file attachment previews it or downloads it immediately",
@@ -1739,7 +1763,7 @@
"use_address": "Use This Address",
"invalid_tag": "Tag must be alphanumeric and dashes only",
"tag_too_long": "Tag must be 30 characters or less",
"help_text": "Emails sent to user+tag@domain.com will arrive in your inbox",
"help_text": "Emails sent to user{delimiter}tag@domain.com will arrive in your inbox",
"validation": {
"empty": "Tag cannot be empty",
"too_long": "Tag must be {max} characters or less",
@@ -2272,6 +2296,20 @@
"sat": "Sat",
"sun": "Sun"
},
"months": {
"jan": "Jan",
"feb": "Feb",
"mar": "Mar",
"apr": "Apr",
"may": "May",
"jun": "Jun",
"jul": "Jul",
"aug": "Aug",
"sep": "Sep",
"oct": "Oct",
"nov": "Nov",
"dec": "Dec"
},
"notifications": {
"event_created": "Event created",
"event_updated": "Event updated",
+54 -5
View File
@@ -424,7 +424,9 @@
"cancel_info": "El organizador ha cancelado este evento.",
"event_updated": "Actualización #{sequence}",
"event_status_tentative": "Provisional",
"event_status_cancelled": "Cancelado"
"event_status_cancelled": "Cancelado",
"collapse": "Ocultar detalles",
"expand": "Mostrar detalles"
},
"previous": "Anterior",
"next": "Siguiente",
@@ -508,7 +510,10 @@
"message": "Tu mensaje menciona \"{keyword}\" pero no hay ningún archivo adjunto. ¿Enviar de todos modos?",
"send_anyway": "Enviar de todos modos",
"back": "Volver a editar"
}
},
"add_link": "Añadir enlace",
"link_url_prompt": "Introduce la URL",
"sending": "Enviando..."
},
"confirm_dialog": {
"confirm": "Confirmar",
@@ -801,6 +806,23 @@
"sound_desc": "Reproducir un sonido para recordatorios del calendario",
"invitation_parsing": "Analizar invitaciones por correo",
"invitation_parsing_desc": "Detectar invitaciones de calendario en archivos adjuntos y mostrar acciones de calendario"
},
"push": {
"confirm_disable_message": "Este dispositivo dejará de recibir alertas cuando el sitio esté cerrado.",
"confirm_disable_title": "¿Desactivar las notificaciones en segundo plano?",
"description": "Recibe notificaciones del sistema para correo nuevo cuando este sitio está cerrado. Se entrega a través del relay push de Bulwark; el relay nunca ve el contenido del correo.",
"disable": "Desactivar",
"enable": "Activar",
"ios_hint": "En iOS, instala primero el sitio en la pantalla de inicio: Safari solo entrega Web Push a PWAs instaladas.",
"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_placeholder": "https://notifications.relay.example.com",
"status_active": "Activo en este dispositivo",
"status_busy": "Trabajando…",
"status_inactive": "No habilitado en este dispositivo",
"status_unsupported": "Este navegador no admite Web Push",
"title": "Notificaciones en segundo plano"
}
},
"language_region": {
@@ -884,6 +906,13 @@
"label": "Seleccionar dirección de respuesta automáticamente",
"description": "Al responder, cambia automáticamente la dirección del remitente a la identidad que recibió el mensaje original"
},
"sub_address_delimiter": {
"label": "Delimitador de sub-dirección",
"description": "Carácter que separa tu nombre de usuario de la etiqueta de sub-dirección. Usa el delimitador que utilice tu servidor de correo (por ejemplo, usuario{delimiter}etiqueta@dominio.com).",
"option": "{delimiter} (usuario{delimiter}etiqueta@dominio.com)",
"custom": "Personalizado…",
"custom_input_label": "Carácter delimitador personalizado"
},
"show_preview": {
"label": "Mostrar Vista Previa",
"description": "Mostrar vista previa del correo en la lista",
@@ -1333,7 +1362,9 @@
"no_address_books": "No se encontraron libretas de direcciones",
"categories_title": "Categorías",
"categories_description": "Renombrar categorías de contactos",
"no_categories": "No se encontraron categorías"
"no_categories": "No se encontraron categorías",
"group_by_letter_description": "Mostrar encabezados alfabéticos en la lista de contactos",
"group_by_letter_label": "Agrupar por primera letra"
},
"filters": {
"title": "Filtros de correo",
@@ -1732,7 +1763,7 @@
"use_address": "Usar Esta Dirección",
"invalid_tag": "La etiqueta debe ser solo alfanumérica y guiones",
"tag_too_long": "La etiqueta debe tener 30 caracteres o menos",
"help_text": "Los correos enviados a usuario+etiqueta@dominio.com llegarán a su bandeja de entrada",
"help_text": "Los correos enviados a usuario{delimiter}etiqueta@dominio.com llegarán a su bandeja de entrada",
"validation": {
"empty": "La etiqueta no puede estar vacía",
"too_long": "La etiqueta debe tener {max} caracteres o menos",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "Mañana",
"export_ics": "Exportar como .ics",
"copy_title": "Copiar título",
"copy_link": "Copiar enlace de reunión"
"copy_link": "Copiar enlace de reunión",
"go_to_today": "Ir a hoy",
"new_all_day_event": "Nuevo evento de todo el día",
"new_event": "Nuevo evento",
"new_task": "Nueva tarea"
},
"detail": {
"add_note": "Añadir una nota...",
@@ -2428,6 +2463,20 @@
"due_today": "Vence hoy",
"due_tomorrow": "Vence mañana",
"overdue": "Vencida"
},
"months": {
"jan": "Ene",
"feb": "Feb",
"mar": "Mar",
"apr": "Abr",
"may": "May",
"jun": "Jun",
"jul": "Jul",
"aug": "Ago",
"sep": "Sep",
"oct": "Oct",
"nov": "Nov",
"dec": "Dic"
}
},
"advanced_search": {
+54 -5
View File
@@ -424,7 +424,9 @@
"cancel_info": "L'organisateur a annulé cet événement.",
"event_updated": "Mise à jour #{sequence}",
"event_status_tentative": "Provisoire",
"event_status_cancelled": "Annulé"
"event_status_cancelled": "Annulé",
"collapse": "Masquer les détails",
"expand": "Afficher les détails"
},
"previous": "Précédent",
"next": "Suivant",
@@ -508,7 +510,10 @@
"message": "Votre message mentionne \"{keyword}\" mais aucun fichier n'est joint. Envoyer quand même ?",
"send_anyway": "Envoyer quand même",
"back": "Retour à l'édition"
}
},
"add_link": "Ajouter un lien",
"link_url_prompt": "Saisissez l'URL",
"sending": "Envoi..."
},
"confirm_dialog": {
"confirm": "Confirmer",
@@ -801,6 +806,23 @@
"sound_desc": "Jouer un son pour les rappels de calendrier",
"invitation_parsing": "Analyser les invitations par e-mail",
"invitation_parsing_desc": "Détecter les invitations de calendrier dans les pièces jointes et afficher les actions de calendrier"
},
"push": {
"confirm_disable_message": "Cet appareil cessera de recevoir des alertes lorsque le site est fermé.",
"confirm_disable_title": "Désactiver les notifications en arrière-plan ?",
"description": "Recevez des notifications système pour les nouveaux courriers quand ce site est fermé. Livré via le relais push Bulwark ; le relais ne voit jamais le contenu des courriers.",
"disable": "Désactiver",
"enable": "Activer",
"ios_hint": "Sur iOS, installez d'abord le site sur l'écran d'accueil Safari ne livre Web Push qu'aux PWA installées.",
"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_placeholder": "https://notifications.relay.example.com",
"status_active": "Actif sur cet appareil",
"status_busy": "Traitement en cours…",
"status_inactive": "Non activé sur cet appareil",
"status_unsupported": "Ce navigateur ne prend pas en charge Web Push",
"title": "Notifications en arrière-plan"
}
},
"language_region": {
@@ -884,6 +906,13 @@
"label": "Sélection automatique de l'adresse de réponse",
"description": "Lors d'une réponse, bascule automatiquement l'adresse d'expédition vers l'identité qui a reçu le message d'origine"
},
"sub_address_delimiter": {
"label": "Délimiteur de sous-adresse",
"description": "Caractère séparant votre nom d'utilisateur de l'étiquette de sous-adresse. Utilisez le délimiteur configuré sur votre serveur de messagerie (par ex. utilisateur{delimiter}tag@domaine.com).",
"option": "{delimiter} (utilisateur{delimiter}tag@domaine.com)",
"custom": "Personnalisé…",
"custom_input_label": "Caractère de délimiteur personnalisé"
},
"show_preview": {
"label": "Afficher l'aperçu",
"description": "Afficher l'aperçu de l'email dans la liste",
@@ -1333,7 +1362,9 @@
"no_address_books": "Aucun carnet d'adresses trouvé",
"categories_title": "Catégories",
"categories_description": "Renommer les catégories de contacts",
"no_categories": "Aucune catégorie trouvée"
"no_categories": "Aucune catégorie trouvée",
"group_by_letter_description": "Afficher des en-têtes alphabétiques dans la liste de contacts",
"group_by_letter_label": "Grouper par première lettre"
},
"filters": {
"title": "Filtres de courrier",
@@ -1732,7 +1763,7 @@
"use_address": "Utiliser cette adresse",
"invalid_tag": "Le tag doit contenir uniquement des lettres, chiffres et tirets",
"tag_too_long": "Le tag doit faire 30 caractères ou moins",
"help_text": "Les emails envoyés à utilisateur+tag@domaine.com arriveront dans votre boîte de réception",
"help_text": "Les emails envoyés à utilisateur{delimiter}tag@domaine.com arriveront dans votre boîte de réception",
"validation": {
"empty": "Le tag ne peut pas être vide",
"too_long": "Le tag doit faire {max} caractères ou moins",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "Demain",
"export_ics": "Exporter en .ics",
"copy_title": "Copier le titre",
"copy_link": "Copier le lien de réunion"
"copy_link": "Copier le lien de réunion",
"go_to_today": "Aller à aujourd'hui",
"new_all_day_event": "Nouvel événement sur la journée",
"new_event": "Nouvel événement",
"new_task": "Nouvelle tâche"
},
"detail": {
"add_note": "Ajouter une note...",
@@ -2261,6 +2296,20 @@
"sat": "Sam",
"sun": "Dim"
},
"months": {
"jan": "janv.",
"feb": "févr.",
"mar": "mars",
"apr": "avr.",
"may": "mai",
"jun": "juin",
"jul": "juil.",
"aug": "août",
"sep": "sept.",
"oct": "oct.",
"nov": "nov.",
"dec": "déc."
},
"notifications": {
"event_created": "Événement créé",
"event_updated": "Événement mis à jour",
+54 -5
View File
@@ -424,7 +424,9 @@
"cancel_info": "L'organizzatore ha annullato questo evento.",
"event_updated": "Aggiornamento #{sequence}",
"event_status_tentative": "Provvisorio",
"event_status_cancelled": "Annullato"
"event_status_cancelled": "Annullato",
"collapse": "Nascondi dettagli",
"expand": "Mostra dettagli"
},
"previous": "Precedente",
"next": "Successivo",
@@ -508,7 +510,10 @@
"message": "Il tuo messaggio menziona \"{keyword}\" ma nessun file è allegato. Inviare comunque?",
"send_anyway": "Invia comunque",
"back": "Torna alla modifica"
}
},
"add_link": "Aggiungi link",
"link_url_prompt": "Inserisci l'URL",
"sending": "Invio in corso..."
},
"confirm_dialog": {
"confirm": "Conferma",
@@ -801,6 +806,23 @@
"sound_desc": "Riproduci un suono per i promemoria del calendario",
"invitation_parsing": "Analizza inviti via e-mail",
"invitation_parsing_desc": "Rileva inviti calendario negli allegati e mostra azioni calendario"
},
"push": {
"confirm_disable_message": "Questo dispositivo non riceverà più avvisi quando il sito è chiuso.",
"confirm_disable_title": "Disabilitare le notifiche in background?",
"description": "Ricevi notifiche di sistema per la nuova posta quando questo sito è chiuso. Consegnato tramite il relay push Bulwark; il relay non vede mai il contenuto della posta.",
"disable": "Disabilita",
"enable": "Abilita",
"ios_hint": "Su iOS, installa prima il sito sulla schermata Home: Safari consegna Web Push solo alle PWA installate.",
"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_placeholder": "https://notifications.relay.example.com",
"status_active": "Attivo su questo dispositivo",
"status_busy": "In elaborazione…",
"status_inactive": "Non abilitato su questo dispositivo",
"status_unsupported": "Questo browser non supporta Web Push",
"title": "Notifiche in background"
}
},
"language_region": {
@@ -884,6 +906,13 @@
"label": "Seleziona automaticamente l'indirizzo di risposta",
"description": "Quando rispondi, passa automaticamente l'indirizzo mittente all'identità che ha ricevuto il messaggio originale"
},
"sub_address_delimiter": {
"label": "Delimitatore sub-indirizzo",
"description": "Carattere che separa il tuo nome utente dall'etichetta del sub-indirizzo. Usa il delimitatore configurato sul tuo server di posta (es. utente{delimiter}tag@dominio.com).",
"option": "{delimiter} (utente{delimiter}tag@dominio.com)",
"custom": "Personalizzato…",
"custom_input_label": "Carattere delimitatore personalizzato"
},
"show_preview": {
"label": "Mostra anteprima testo",
"description": "Visualizza l'anteprima del messaggio nell'elenco",
@@ -1333,7 +1362,9 @@
"no_address_books": "Nessuna rubrica trovata",
"categories_title": "Categorie",
"categories_description": "Rinomina le categorie dei contatti",
"no_categories": "Nessuna categoria trovata"
"no_categories": "Nessuna categoria trovata",
"group_by_letter_description": "Mostra intestazioni alfabetiche nell'elenco dei contatti",
"group_by_letter_label": "Raggruppa per prima lettera"
},
"filters": {
"title": "Filtri email",
@@ -1732,7 +1763,7 @@
"use_address": "Usa questo indirizzo",
"invalid_tag": "Il tag deve contenere solo caratteri alfanumerici e trattini",
"tag_too_long": "Il tag deve essere di massimo 30 caratteri",
"help_text": "I messaggi inviati a utente+tag@dominio.com arriveranno nella tua casella di posta",
"help_text": "I messaggi inviati a utente{delimiter}tag@dominio.com arriveranno nella tua casella di posta",
"validation": {
"empty": "Il tag non può essere vuoto",
"too_long": "Il tag deve essere di massimo {max} caratteri",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "Domani",
"export_ics": "Esporta come .ics",
"copy_title": "Copia titolo",
"copy_link": "Copia link riunione"
"copy_link": "Copia link riunione",
"go_to_today": "Vai a oggi",
"new_all_day_event": "Nuovo evento giornata intera",
"new_event": "Nuovo evento",
"new_task": "Nuova attività"
},
"detail": {
"add_note": "Aggiungi una nota...",
@@ -2428,6 +2463,20 @@
"due_today": "Scade oggi",
"due_tomorrow": "Scade domani",
"overdue": "Scaduta"
},
"months": {
"jan": "gen",
"feb": "feb",
"mar": "mar",
"apr": "apr",
"may": "mag",
"jun": "giu",
"jul": "lug",
"aug": "ago",
"sep": "set",
"oct": "ott",
"nov": "nov",
"dec": "dic"
}
},
"advanced_search": {
+54 -5
View File
@@ -424,7 +424,9 @@
"cancel_info": "主催者がこのイベントをキャンセルしました。",
"event_updated": "更新 #{sequence}",
"event_status_tentative": "仮",
"event_status_cancelled": "キャンセル済み"
"event_status_cancelled": "キャンセル済み",
"collapse": "詳細を非表示",
"expand": "詳細を表示"
},
"previous": "前へ",
"next": "次へ",
@@ -508,7 +510,10 @@
"message": "メッセージに「{keyword}」が含まれていますが、ファイルが添付されていません。このまま送信しますか?",
"send_anyway": "そのまま送信",
"back": "編集に戻る"
}
},
"add_link": "リンクを追加",
"link_url_prompt": "URLを入力してください",
"sending": "送信中..."
},
"confirm_dialog": {
"confirm": "確認",
@@ -801,6 +806,23 @@
"sound_desc": "カレンダーリマインダーの音を鳴らす",
"invitation_parsing": "メール招待を解析",
"invitation_parsing_desc": "メール添付ファイルのカレンダー招待を検出し、カレンダーアクションを表示"
},
"push": {
"confirm_disable_message": "このデバイスは、サイトが閉じているときに通知を受信しなくなります。",
"confirm_disable_title": "バックグラウンド通知を無効にしますか?",
"description": "このサイトが閉じているときに新着メールのシステム通知を受信します。Bulwark プッシュリレー経由で配信され、リレーがメール内容を見ることはありません。",
"disable": "無効化",
"enable": "有効化",
"ios_hint": "iOS では、最初にサイトをホーム画面にインストールしてください。Safari はインストールされた PWA にのみ Web Push を配信します。",
"reenable": "再登録",
"relay_desc": "デフォルトはホストされた Bulwark リレーです。セルフホストする場合のみ変更してください。",
"relay_label": "プッシュリレー",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "このデバイスで有効",
"status_busy": "処理中…",
"status_inactive": "このデバイスでは有効になっていません",
"status_unsupported": "このブラウザは Web Push をサポートしていません",
"title": "バックグラウンド通知"
}
},
"language_region": {
@@ -884,6 +906,13 @@
"label": "返信元アドレスを自動選択",
"description": "返信時に、元のメッセージを受信したIDへ差出人アドレスを自動的に切り替えます"
},
"sub_address_delimiter": {
"label": "サブアドレス区切り文字",
"description": "ユーザー名とサブアドレスタグを区切る文字です。お使いのメールサーバーが使用する区切り文字に合わせてください(例: user{delimiter}tag@domain.com)。",
"option": "{delimiter} (user{delimiter}tag@domain.com)",
"custom": "カスタム…",
"custom_input_label": "カスタム区切り文字"
},
"show_preview": {
"label": "プレビューテキストを表示",
"description": "リストにメールのプレビューを表示",
@@ -1333,7 +1362,9 @@
"no_address_books": "アドレス帳が見つかりません",
"categories_title": "カテゴリ",
"categories_description": "連絡先カテゴリの名前を変更",
"no_categories": "カテゴリが見つかりません"
"no_categories": "カテゴリが見つかりません",
"group_by_letter_description": "連絡先リストにアルファベット順のセクション見出しを表示",
"group_by_letter_label": "頭文字でグループ化"
},
"filters": {
"title": "メールフィルター",
@@ -1732,7 +1763,7 @@
"use_address": "このアドレスを使用",
"invalid_tag": "タグは英数字とハイフンのみ使用できます",
"tag_too_long": "タグは30文字以内にしてください",
"help_text": "user+tag@domain.comに送信されたメールは受信トレイに届きます",
"help_text": "user{delimiter}tag@domain.comに送信されたメールは受信トレイに届きます",
"validation": {
"empty": "タグは空にできません",
"too_long": "タグは{max}文字以内にしてください",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "明日",
"export_ics": ".icsとしてエクスポート",
"copy_title": "タイトルをコピー",
"copy_link": "会議リンクをコピー"
"copy_link": "会議リンクをコピー",
"go_to_today": "今日に移動",
"new_all_day_event": "新しい終日イベント",
"new_event": "新しいイベント",
"new_task": "新しいタスク"
},
"detail": {
"add_note": "メモを追加...",
@@ -2428,6 +2463,20 @@
"due_today": "今日が期限",
"due_tomorrow": "明日が期限",
"overdue": "期限切れ"
},
"months": {
"jan": "1月",
"feb": "2月",
"mar": "3月",
"apr": "4月",
"may": "5月",
"jun": "6月",
"jul": "7月",
"aug": "8月",
"sep": "9月",
"oct": "10月",
"nov": "11月",
"dec": "12月"
}
},
"advanced_search": {
+54 -5
View File
@@ -426,7 +426,9 @@
"cancel_info": "주최자가 이 일정을 취소했어요.",
"event_updated": "업데이트 #{sequence}",
"event_status_tentative": "미정",
"event_status_cancelled": "취소됨"
"event_status_cancelled": "취소됨",
"collapse": "세부정보 숨기기",
"expand": "세부정보 표시"
},
"send": "보내기",
"more": "더보기"
@@ -508,7 +510,10 @@
"message": "메시지에 \"{keyword}\"이(가) 언급되어 있지만 파일이 첨부되지 않았습니다. 그래도 보내시겠습니까?",
"send_anyway": "그래도 보내기",
"back": "편집으로 돌아가기"
}
},
"add_link": "링크 추가",
"link_url_prompt": "URL을 입력하세요",
"sending": "전송 중..."
},
"confirm_dialog": {
"confirm": "확인",
@@ -801,6 +806,23 @@
"sound_desc": "일정 알림이 올 때 소리로 알려줘요",
"invitation_parsing": "이메일 초대장 분석",
"invitation_parsing_desc": "이메일 첨부파일에서 캘린더 초대장을 감지하고 캘린더에 표시해요"
},
"push": {
"confirm_disable_message": "이 사이트가 닫혀 있을 때 이 기기는 알림을 더 이상 받지 않습니다.",
"confirm_disable_title": "백그라운드 알림을 비활성화하시겠습니까?",
"description": "이 사이트가 닫혀 있을 때 새 메일에 대한 시스템 알림을 받습니다. Bulwark 푸시 릴레이를 통해 전달되며, 릴레이는 메일 내용을 절대 보지 않습니다.",
"disable": "비활성화",
"enable": "활성화",
"ios_hint": "iOS에서는 먼저 사이트를 홈 화면에 설치하세요. Safari는 설치된 PWA에만 Web Push를 전달합니다.",
"reenable": "다시 등록",
"relay_desc": "기본값은 호스팅된 Bulwark 릴레이입니다. 셀프 호스팅 시에만 변경하세요.",
"relay_label": "푸시 릴레이",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "이 기기에서 활성",
"status_busy": "처리 중…",
"status_inactive": "이 기기에서 활성화되지 않음",
"status_unsupported": "이 브라우저는 Web Push를 지원하지 않습니다",
"title": "백그라운드 알림"
}
},
"language_region": {
@@ -889,6 +911,13 @@
"label": "답장 시 보내는 사람 자동 선택",
"description": "답장할 때 메일을 받았던 주소로 보내는 사람을 자동으로 변경해요"
},
"sub_address_delimiter": {
"label": "서브 주소 구분자",
"description": "사용자 이름과 서브 주소 태그를 나누는 문자예요. 메일 서버가 사용하는 구분자에 맞춰 주세요 (예: user{delimiter}tag@domain.com).",
"option": "{delimiter} (user{delimiter}tag@domain.com)",
"custom": "사용자 지정…",
"custom_input_label": "사용자 지정 구분 문자"
},
"attachment_click_action": {
"label": "첨부파일 클릭 동작",
"description": "파일을 클릭했을 때 미리보기를 할지, 바로 다운로드할지 선택해 주세요",
@@ -1333,7 +1362,9 @@
"no_address_books": "주소록을 찾을 수 없음",
"categories_title": "카테고리",
"categories_description": "연락처 카테고리 이름 변경",
"no_categories": "카테고리를 찾을 수 없음"
"no_categories": "카테고리를 찾을 수 없음",
"group_by_letter_description": "연락처 목록에 알파벳순 섹션 헤더 표시",
"group_by_letter_label": "첫 글자로 그룹화"
},
"filters": {
"title": "이메일 필터",
@@ -1732,7 +1763,7 @@
"use_address": "이 주소 사용하기",
"invalid_tag": "태그는 알파벳, 숫자, 대시(-)만 쓸 수 있어요",
"tag_too_long": "태그는 30자 이하여야 해요",
"help_text": "user+tag@domain.com 으로 보낸 메일은 내 받은편지함으로 들어와요",
"help_text": "user{delimiter}tag@domain.com 으로 보낸 메일은 내 받은편지함으로 들어와요",
"validation": {
"empty": "태그를 비워둘 수 없어요",
"too_long": "태그는 {max}자 이하여야 해요",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "내일",
"export_ics": ".ics로 내보내기",
"copy_title": "제목 복사",
"copy_link": "회의 링크 복사"
"copy_link": "회의 링크 복사",
"go_to_today": "오늘로 이동",
"new_all_day_event": "새 종일 이벤트",
"new_event": "새 이벤트",
"new_task": "새 작업"
},
"detail": {
"add_note": "메모 추가...",
@@ -2428,6 +2463,20 @@
"due_today": "오늘 마감",
"due_tomorrow": "내일 마감",
"overdue": "기한 지남"
},
"months": {
"jan": "1월",
"feb": "2월",
"mar": "3월",
"apr": "4월",
"may": "5월",
"jun": "6월",
"jul": "7월",
"aug": "8월",
"sep": "9월",
"oct": "10월",
"nov": "11월",
"dec": "12월"
}
},
"advanced_search": {
+55 -6
View File
@@ -426,7 +426,9 @@
"cancel_info": "Organizators atcēla šo pasākumu.",
"event_updated": "Atjauninājums Nr. {sequence}",
"event_status_tentative": "Pagaidām",
"event_status_cancelled": "Atcelts"
"event_status_cancelled": "Atcelts",
"collapse": "Paslēpt detaļas",
"expand": "Rādīt detaļas"
},
"send": "Sūtīt",
"more": "vairāk"
@@ -508,7 +510,10 @@
"message": "Jūsu ziņojumā minēts \"{keyword}\", bet nav pievienots neviens fails. Vai tomēr sūtīt?",
"send_anyway": "Sūtīt tik un tā",
"back": "Atpakaļ pie rediģēšanas"
}
},
"add_link": "Pievienot saiti",
"link_url_prompt": "Ievadiet URL",
"sending": "Sūta..."
},
"confirm_dialog": {
"confirm": "Apstiprināt",
@@ -801,6 +806,23 @@
"sound_desc": "Atskaņot skaņas signālu kalendāra atgādinājumiem",
"invitation_parsing": "Atpazīt uzaicinājumus e-pastā",
"invitation_parsing_desc": "Noteikt kalendāra uzaicinājumus pielikumos un rādīt kalendāra darbības"
},
"push": {
"confirm_disable_message": "Šī ierīce vairs nesaņems brīdinājumus, kad vietne būs aizvērta.",
"confirm_disable_title": "Atspējot fona paziņojumus?",
"description": "Saņem sistēmas paziņojumus par jaunu pastu, kad šī vietne ir aizvērta. Piegādāts caur Bulwark push releju; relejs nekad neredz pasta saturu.",
"disable": "Atspējot",
"enable": "Iespējot",
"ios_hint": "Operētājsistēmā iOS vispirms instalējiet vietni sākuma ekrānā Safari piegādā Web Push tikai instalētajām PWA.",
"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_placeholder": "https://notifications.relay.example.com",
"status_active": "Aktīvs šajā ierīcē",
"status_busy": "Notiek darbs…",
"status_inactive": "Nav iespējots šajā ierīcē",
"status_unsupported": "Šī pārlūkprogramma neatbalsta Web Push",
"title": "Fona paziņojumi"
}
},
"language_region": {
@@ -884,6 +906,13 @@
"label": "Automātiski izvēlēties atbildes adresi",
"description": "Atbildot automātiski izmantot to kontu, uz kuru vēstule tika saņemta"
},
"sub_address_delimiter": {
"label": "Apakšadreses atdalītājs",
"description": "Zīme, kas atdala lietotājvārdu no apakšadreses tagu. Izvēlieties atdalītāju, ko lieto jūsu pasta serveris (piem. lietotajs{delimiter}tags@domens.lv).",
"option": "{delimiter} (lietotajs{delimiter}tags@domens.lv)",
"custom": "Pielāgots…",
"custom_input_label": "Pielāgota atdalītāja zīme"
},
"show_preview": {
"label": "Rādīt priekšskatījuma tekstu",
"description": "Rādīt vēstules fragmentu sarakstā",
@@ -1333,7 +1362,9 @@
"no_address_books": "Adrešu grāmatas nav atrastas",
"categories_title": "Kategorijas",
"categories_description": "Pārdēvēt kontaktu kategorijas",
"no_categories": "Kategorijas nav atrastas"
"no_categories": "Kategorijas nav atrastas",
"group_by_letter_description": "Rādīt alfabētiskos sadaļu virsrakstus kontaktu sarakstā",
"group_by_letter_label": "Grupēt pēc pirmā burta"
},
"filters": {
"title": "Pasta filtri",
@@ -1732,7 +1763,7 @@
"use_address": "Izmantot šo adresi",
"invalid_tag": "Tags var saturēt tikai burtus, ciparus un domuzīmes",
"tag_too_long": "Tags nedrīkst pārsniegt 30 rakstzīmes",
"help_text": "Vēstules uz lietotajs+tags@domens.lv nonāks jūsu pastkastē",
"help_text": "Vēstules uz lietotajs{delimiter}tags@domens.lv nonāks jūsu pastkastē",
"validation": {
"empty": "Tags nevar būt tukšs",
"too_long": "Tags nedrīkst pārsniegt {max} rakstzīmes",
@@ -2128,7 +2159,11 @@
"tomorrow_header": "Rīt",
"export_ics": "Eksportēt kā .ics",
"copy_title": "Kopēt nosaukumu",
"copy_link": "Kopēt sapulces saiti"
"copy_link": "Kopēt sapulces saiti",
"go_to_today": "Pāriet uz šodienu",
"new_all_day_event": "Jauns visas dienas notikums",
"new_event": "Jauns notikums",
"new_task": "Jauns uzdevums"
},
"detail": {
"add_note": "Pievienot piezīmi...",
@@ -2428,7 +2463,21 @@
"due_tomorrow": "Rīt",
"overdue": "Kavēts"
},
"birthday_calendar": "Dzimšanas dienas"
"birthday_calendar": "Dzimšanas dienas",
"months": {
"jan": "janv.",
"feb": "febr.",
"mar": "marts",
"apr": "apr.",
"may": "maijs",
"jun": "jūn.",
"jul": "jūl.",
"aug": "aug.",
"sep": "sept.",
"oct": "okt.",
"nov": "nov.",
"dec": "dec."
}
},
"advanced_search": {
"title": "Izvērstā meklēšana",
+54 -5
View File
@@ -424,7 +424,9 @@
"cancel_info": "De organisator heeft dit evenement geannuleerd.",
"event_updated": "Update #{sequence}",
"event_status_tentative": "Voorlopig",
"event_status_cancelled": "Geannuleerd"
"event_status_cancelled": "Geannuleerd",
"collapse": "Details verbergen",
"expand": "Details tonen"
},
"previous": "Vorige",
"next": "Volgende",
@@ -508,7 +510,10 @@
"message": "Uw bericht vermeldt \"{keyword}\" maar er is geen bestand bijgevoegd. Toch verzenden?",
"send_anyway": "Toch verzenden",
"back": "Terug naar bewerken"
}
},
"add_link": "Link toevoegen",
"link_url_prompt": "Voer de URL in",
"sending": "Bezig met verzenden..."
},
"confirm_dialog": {
"confirm": "Bevestigen",
@@ -801,6 +806,23 @@
"sound_desc": "Een geluid afspelen voor agendaherinneringen",
"invitation_parsing": "E-mailuitnodigingen herkennen",
"invitation_parsing_desc": "Agenda-uitnodigingen in e-mailbijlagen detecteren en agendaacties tonen"
},
"push": {
"confirm_disable_message": "Dit apparaat ontvangt geen meldingen meer als de site is gesloten.",
"confirm_disable_title": "Achtergrondmeldingen uitschakelen?",
"description": "Ontvang systeemmeldingen voor nieuwe e-mail wanneer deze site gesloten is. Geleverd via de Bulwark-pushrelay; de relay ziet nooit e-mailinhoud.",
"disable": "Uitschakelen",
"enable": "Inschakelen",
"ios_hint": "Installeer de site op iOS eerst op het beginscherm Safari levert Web Push alleen aan geïnstalleerde PWA's.",
"reenable": "Opnieuw registreren",
"relay_desc": "Gebruikt standaard de gehoste Bulwark-relay. Wijzig alleen als je zelf hostt.",
"relay_label": "Push-relay",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Actief op dit apparaat",
"status_busy": "Bezig…",
"status_inactive": "Niet ingeschakeld op dit apparaat",
"status_unsupported": "Deze browser ondersteunt geen Web Push",
"title": "Achtergrondmeldingen"
}
},
"language_region": {
@@ -884,6 +906,13 @@
"label": "Antwoordadres automatisch selecteren",
"description": "Schakel bij het beantwoorden automatisch het Van-adres om naar de identiteit die het oorspronkelijke bericht ontving"
},
"sub_address_delimiter": {
"label": "Sub-adres scheidingsteken",
"description": "Teken dat je gebruikersnaam scheidt van het sub-adres-label. Gebruik het scheidingsteken dat je mailserver gebruikt (bv. gebruiker{delimiter}tag@domein.nl).",
"option": "{delimiter} (gebruiker{delimiter}tag@domein.nl)",
"custom": "Aangepast…",
"custom_input_label": "Aangepast scheidingsteken"
},
"show_preview": {
"label": "Voorbeeldtekst tonen",
"description": "E-mailvoorbeeld weergeven in de lijst",
@@ -1333,7 +1362,9 @@
"no_address_books": "Geen adresboeken gevonden",
"categories_title": "Categorieën",
"categories_description": "Contactcategorieën hernoemen",
"no_categories": "Geen categorieën gevonden"
"no_categories": "Geen categorieën gevonden",
"group_by_letter_description": "Toon alfabetische sectiekoppen in de contactenlijst",
"group_by_letter_label": "Groeperen op eerste letter"
},
"filters": {
"title": "E-mailfilters",
@@ -1732,7 +1763,7 @@
"use_address": "Dit adres gebruiken",
"invalid_tag": "Tag mag alleen letters, cijfers en streepjes bevatten",
"tag_too_long": "Tag mag maximaal 30 tekens bevatten",
"help_text": "E-mails verzonden naar gebruiker+tag@domein.nl komen in je postvak IN aan",
"help_text": "E-mails verzonden naar gebruiker{delimiter}tag@domein.nl komen in je postvak IN aan",
"validation": {
"empty": "Tag mag niet leeg zijn",
"too_long": "Tag mag maximaal {max} tekens bevatten",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "Morgen",
"export_ics": "Exporteren als .ics",
"copy_title": "Titel kopiëren",
"copy_link": "Vergaderlink kopiëren"
"copy_link": "Vergaderlink kopiëren",
"go_to_today": "Ga naar vandaag",
"new_all_day_event": "Nieuwe dagvullende afspraak",
"new_event": "Nieuwe afspraak",
"new_task": "Nieuwe taak"
},
"detail": {
"add_note": "Notitie toevoegen...",
@@ -2428,6 +2463,20 @@
"due_today": "Vervalt vandaag",
"due_tomorrow": "Vervalt morgen",
"overdue": "Achterstallig"
},
"months": {
"jan": "jan",
"feb": "feb",
"mar": "mrt",
"apr": "apr",
"may": "mei",
"jun": "jun",
"jul": "jul",
"aug": "aug",
"sep": "sep",
"oct": "okt",
"nov": "nov",
"dec": "dec"
}
},
"advanced_search": {
+54 -5
View File
@@ -426,7 +426,9 @@
"cancel_info": "Organizator anulował to wydarzenie.",
"event_updated": "Aktualizacja #{sequence}",
"event_status_tentative": "Wstępne",
"event_status_cancelled": "Anulowane"
"event_status_cancelled": "Anulowane",
"collapse": "Ukryj szczegóły",
"expand": "Pokaż szczegóły"
},
"send": "Wyślij",
"more": "więcej"
@@ -508,7 +510,10 @@
"message": "Twoja wiadomość wspomina o \"{keyword}\", ale nie załączono żadnego pliku. Wysłać mimo to?",
"send_anyway": "Wyślij mimo to",
"back": "Wróć do edycji"
}
},
"add_link": "Dodaj link",
"link_url_prompt": "Wprowadź adres URL",
"sending": "Wysyłanie..."
},
"confirm_dialog": {
"confirm": "Potwierdź",
@@ -801,6 +806,23 @@
"sound_desc": "Odtwarzaj sygnał dźwiękowy dla przypomnień kalendarza",
"invitation_parsing": "Rozpoznawaj zaproszenia e-mail",
"invitation_parsing_desc": "Wykrywaj zaproszenia kalendarzowe w załącznikach wiadomości e-mail i pokazuj akcje kalendarza"
},
"push": {
"confirm_disable_message": "To urządzenie przestanie otrzymywać powiadomienia, gdy strona jest zamknięta.",
"confirm_disable_title": "Wyłączyć powiadomienia w tle?",
"description": "Otrzymuj powiadomienia systemowe o nowych wiadomościach, gdy ta strona jest zamknięta. Dostarczane przez przekaźnik push Bulwark; przekaźnik nigdy nie widzi treści wiadomości.",
"disable": "Wyłącz",
"enable": "Włącz",
"ios_hint": "W systemie iOS najpierw zainstaluj stronę na ekranie głównym Safari dostarcza Web Push tylko zainstalowanym PWA.",
"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_placeholder": "https://notifications.relay.example.com",
"status_active": "Aktywne na tym urządzeniu",
"status_busy": "Przetwarzanie…",
"status_inactive": "Niewłączone na tym urządzeniu",
"status_unsupported": "Ta przeglądarka nie obsługuje Web Push",
"title": "Powiadomienia w tle"
}
},
"language_region": {
@@ -889,6 +911,13 @@
"label": "Automatycznie wybieraj adres odpowiedzi",
"description": "Podczas odpowiadania automatycznie przełączaj adres nadawcy na tożsamość, która pierwotnie otrzymała wiadomość"
},
"sub_address_delimiter": {
"label": "Separator sub-adresu",
"description": "Znak oddzielający Twoją nazwę użytkownika od tagu sub-adresu. Użyj separatora zgodnego z Twoim serwerem pocztowym (np. user{delimiter}tag@domain.com).",
"option": "{delimiter} (user{delimiter}tag@domain.com)",
"custom": "Niestandardowy…",
"custom_input_label": "Niestandardowy znak separatora"
},
"attachment_click_action": {
"label": "Akcja po kliknięciu załącznika",
"description": "Wybierz, czy kliknięcie załącznika pliku ma pokazać podgląd, czy od razu go pobrać",
@@ -1333,7 +1362,9 @@
"no_address_books": "Nie znaleziono książek adresowych",
"categories_title": "Kategorie",
"categories_description": "Zmień nazwy kategorii kontaktów",
"no_categories": "Nie znaleziono kategorii"
"no_categories": "Nie znaleziono kategorii",
"group_by_letter_description": "Pokaż alfabetyczne nagłówki sekcji na liście kontaktów",
"group_by_letter_label": "Grupuj według pierwszej litery"
},
"filters": {
"title": "Filtry wiadomości e-mail",
@@ -1732,7 +1763,7 @@
"use_address": "Użyj tego adresu",
"invalid_tag": "Tag może zawierać tylko litery, cyfry i myślniki",
"tag_too_long": "Tag może mieć maksymalnie 30 znaków",
"help_text": "Wiadomości wysłane na adres user+tag@domain.com trafią do Twojej skrzynki odbiorczej",
"help_text": "Wiadomości wysłane na adres user{delimiter}tag@domain.com trafią do Twojej skrzynki odbiorczej",
"validation": {
"empty": "Tag nie może być pusty",
"too_long": "Tag może mieć maksymalnie {max} znaków",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "Jutro",
"export_ics": "Eksportuj jako .ics",
"copy_title": "Kopiuj tytuł",
"copy_link": "Kopiuj link do spotkania"
"copy_link": "Kopiuj link do spotkania",
"go_to_today": "Przejdź do dzisiaj",
"new_all_day_event": "Nowe wydarzenie całodniowe",
"new_event": "Nowe wydarzenie",
"new_task": "Nowe zadanie"
},
"detail": {
"add_note": "Dodaj notatkę...",
@@ -2428,6 +2463,20 @@
"due_today": "Dzisiaj",
"due_tomorrow": "Jutro",
"overdue": "Zaległe"
},
"months": {
"jan": "sty",
"feb": "lut",
"mar": "mar",
"apr": "kwi",
"may": "maj",
"jun": "cze",
"jul": "lip",
"aug": "sie",
"sep": "wrz",
"oct": "paź",
"nov": "lis",
"dec": "gru"
}
},
"advanced_search": {
+55 -6
View File
@@ -424,7 +424,9 @@
"cancel_info": "O organizador cancelou este evento.",
"event_updated": "Atualização #{sequence}",
"event_status_tentative": "Provisório",
"event_status_cancelled": "Cancelado"
"event_status_cancelled": "Cancelado",
"collapse": "Ocultar detalhes",
"expand": "Mostrar detalhes"
},
"previous": "Anterior",
"next": "Próximo",
@@ -508,7 +510,10 @@
"message": "A sua mensagem menciona \"{keyword}\" mas nenhum arquivo está anexado. Enviar mesmo assim?",
"send_anyway": "Enviar mesmo assim",
"back": "Voltar à edição"
}
},
"add_link": "Adicionar link",
"link_url_prompt": "Insira a URL",
"sending": "Enviando..."
},
"confirm_dialog": {
"confirm": "Confirmar",
@@ -801,6 +806,23 @@
"sound_desc": "Reproduzir um som para lembretes do calendário",
"invitation_parsing": "Analisar convites por e-mail",
"invitation_parsing_desc": "Detectar convites de calendário em anexos de e-mail e mostrar ações de calendário"
},
"push": {
"confirm_disable_message": "Este dispositivo deixará de receber alertas quando o site estiver fechado.",
"confirm_disable_title": "Desativar notificações em segundo plano?",
"description": "Receba notificações do sistema para novas mensagens quando este site estiver fechado. Entregue através do relay push do Bulwark; o relay nunca vê o conteúdo da mensagem.",
"disable": "Desativar",
"enable": "Ativar",
"ios_hint": "No iOS, instale o site primeiro na tela inicial o Safari só entrega Web Push para PWAs instalados.",
"reenable": "Registrar novamente",
"relay_desc": "Usa o relay Bulwark hospedado por padrão. Altere apenas se você hospedar.",
"relay_label": "Relay push",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Ativo neste dispositivo",
"status_busy": "Processando…",
"status_inactive": "Não ativado neste dispositivo",
"status_unsupported": "Este navegador não oferece suporte a Web Push",
"title": "Notificações em segundo plano"
}
},
"language_region": {
@@ -884,6 +906,13 @@
"label": "Selecionar automaticamente o endereço de resposta",
"description": "Ao responder, muda automaticamente o endereço do remetente para a identidade que recebeu a mensagem original"
},
"sub_address_delimiter": {
"label": "Delimitador de sub-endereço",
"description": "Caractere que separa seu nome de usuário da tag de sub-endereço. Use o delimitador configurado no seu servidor de e-mail (ex.: usuario{delimiter}tag@dominio.com).",
"option": "{delimiter} (usuario{delimiter}tag@dominio.com)",
"custom": "Personalizado…",
"custom_input_label": "Caractere delimitador personalizado"
},
"show_preview": {
"label": "Mostrar Texto de Visualização",
"description": "Exibir visualização do e-mail na lista",
@@ -1333,7 +1362,9 @@
"no_address_books": "Nenhum catálogo de endereços encontrado",
"categories_title": "Categorias",
"categories_description": "Renomear categorias de contatos",
"no_categories": "Nenhuma categoria encontrada"
"no_categories": "Nenhuma categoria encontrada",
"group_by_letter_description": "Mostrar cabeçalhos de seção alfabéticos na lista de contatos",
"group_by_letter_label": "Agrupar pela primeira letra"
},
"filters": {
"title": "Filtros de e-mail",
@@ -1732,7 +1763,7 @@
"use_address": "Usar Este Endereço",
"invalid_tag": "A tag deve conter apenas caracteres alfanuméricos e hífens",
"tag_too_long": "A tag deve ter no máximo 30 caracteres",
"help_text": "E-mails enviados para usuario+tag@dominio.com chegarão na sua caixa de entrada",
"help_text": "E-mails enviados para usuario{delimiter}tag@dominio.com chegarão na sua caixa de entrada",
"validation": {
"empty": "A tag não pode estar vazia",
"too_long": "A tag deve ter no máximo {max} caracteres",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "Amanhã",
"export_ics": "Exportar como .ics",
"copy_title": "Copiar título",
"copy_link": "Copiar link da reunião"
"copy_link": "Copiar link da reunião",
"go_to_today": "Ir para hoje",
"new_all_day_event": "Novo evento de dia inteiro",
"new_event": "Novo evento",
"new_task": "Nova tarefa"
},
"detail": {
"add_note": "Adicionar uma nota...",
@@ -2261,6 +2296,20 @@
"sat": "Sáb",
"sun": "Dom"
},
"months": {
"jan": "jan",
"feb": "fev",
"mar": "mar",
"apr": "abr",
"may": "mai",
"jun": "jun",
"jul": "jul",
"aug": "ago",
"sep": "set",
"oct": "out",
"nov": "nov",
"dec": "dez"
},
"notifications": {
"event_created": "Evento criado",
"event_updated": "Evento atualizado",
@@ -2738,4 +2787,4 @@
"custom": "Personalizado"
}
}
}
}
+54 -5
View File
@@ -426,7 +426,9 @@
"cancel_info": "Организатор отменил это событие.",
"event_updated": "Обновление №{sequence}",
"event_status_tentative": "Под вопросом",
"event_status_cancelled": "Отменено"
"event_status_cancelled": "Отменено",
"collapse": "Скрыть детали",
"expand": "Показать детали"
},
"send": "Отправить",
"more": "ещё"
@@ -508,7 +510,10 @@
"message": "В вашем сообщении упоминается \"{keyword}\", но файл не прикреплён. Отправить всё равно?",
"send_anyway": "Отправить всё равно",
"back": "Вернуться к редактированию"
}
},
"add_link": "Добавить ссылку",
"link_url_prompt": "Введите URL",
"sending": "Отправка..."
},
"confirm_dialog": {
"confirm": "Подтвердить",
@@ -801,6 +806,23 @@
"sound_desc": "Воспроизводить звуковой сигнал для напоминаний календаря",
"invitation_parsing": "Распознавать приглашения по почте",
"invitation_parsing_desc": "Обнаруживать приглашения календаря во вложениях писем и показывать действия календаря"
},
"push": {
"confirm_disable_message": "Это устройство перестанет получать оповещения, когда сайт закрыт.",
"confirm_disable_title": "Отключить фоновые уведомления?",
"description": "Получайте системные уведомления о новых письмах, когда этот сайт закрыт. Доставляется через push-релей Bulwark; релей никогда не видит содержимое писем.",
"disable": "Отключить",
"enable": "Включить",
"ios_hint": "В iOS сначала установите сайт на главный экран – Safari доставляет Web Push только установленным PWA.",
"reenable": "Перерегистрировать",
"relay_desc": "По умолчанию используется размещённый релей Bulwark. Меняйте только если хостите самостоятельно.",
"relay_label": "Push-релей",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Активно на этом устройстве",
"status_busy": "Выполняется…",
"status_inactive": "Не включено на этом устройстве",
"status_unsupported": "Этот браузер не поддерживает Web Push",
"title": "Фоновые уведомления"
}
},
"language_region": {
@@ -884,6 +906,13 @@
"label": "Автоматически выбирать адрес для ответа",
"description": "При ответе автоматически переключать адрес отправителя на ту учетную запись, которая получила исходное сообщение"
},
"sub_address_delimiter": {
"label": "Разделитель суб-адресов",
"description": "Символ, отделяющий имя пользователя от тега суб-адреса. Используйте разделитель, настроенный на вашем почтовом сервере (например, user{delimiter}tag@domain.com).",
"option": "{delimiter} (user{delimiter}tag@domain.com)",
"custom": "Свой…",
"custom_input_label": "Свой символ-разделитель"
},
"show_preview": {
"label": "Показывать текст предпросмотра",
"description": "Отображать предпросмотр письма в списке",
@@ -1333,7 +1362,9 @@
"no_address_books": "Адресные книги не найдены",
"categories_title": "Категории",
"categories_description": "Переименование категорий контактов",
"no_categories": "Категории не найдены"
"no_categories": "Категории не найдены",
"group_by_letter_description": "Показывать алфавитные заголовки разделов в списке контактов",
"group_by_letter_label": "Группировать по первой букве"
},
"filters": {
"title": "Фильтры почты",
@@ -1732,7 +1763,7 @@
"use_address": "Использовать этот адрес",
"invalid_tag": "Тег должен содержать только буквы, цифры и дефисы",
"tag_too_long": "Тег не должен превышать 30 символов",
"help_text": "Письма на адрес user+tag@domain.com будут приходить в ваш ящик",
"help_text": "Письма на адрес user{delimiter}tag@domain.com будут приходить в ваш ящик",
"validation": {
"empty": "Тег не может быть пустым",
"too_long": "Тег не должен превышать {max} символов",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "Завтра",
"export_ics": "Экспорт в .ics",
"copy_title": "Скопировать название",
"copy_link": "Скопировать ссылку встречи"
"copy_link": "Скопировать ссылку встречи",
"go_to_today": "Перейти к сегодня",
"new_all_day_event": "Новое событие на весь день",
"new_event": "Новое событие",
"new_task": "Новая задача"
},
"detail": {
"add_note": "Добавить заметку...",
@@ -2428,6 +2463,20 @@
"due_today": "Сегодня",
"due_tomorrow": "Завтра",
"overdue": "Просрочено"
},
"months": {
"jan": "янв.",
"feb": "февр.",
"mar": "март",
"apr": "апр.",
"may": "май",
"jun": "июнь",
"jul": "июль",
"aug": "авг.",
"sep": "сент.",
"oct": "окт.",
"nov": "нояб.",
"dec": "дек."
}
},
"advanced_search": {
File diff suppressed because it is too large Load Diff
+54 -5
View File
@@ -426,7 +426,9 @@
"cancel_info": "Організатор скасував цю подію.",
"event_updated": "Оновлення №{sequence}",
"event_status_tentative": "Орієнтовний",
"event_status_cancelled": "Скасовано"
"event_status_cancelled": "Скасовано",
"collapse": "Сховати деталі",
"expand": "Показати деталі"
},
"send": "Надіслати",
"more": "більше"
@@ -508,7 +510,10 @@
"message": "У вашому повідомленні згадується \"{keyword}\", але файл не вкладено. Все одно надіслати?",
"send_anyway": "Все одно надішліть",
"back": "Назад до редагування"
}
},
"add_link": "Додати посилання",
"link_url_prompt": "Введіть URL",
"sending": "Надсилання..."
},
"confirm_dialog": {
"confirm": "Підтвердити",
@@ -801,6 +806,23 @@
"sound_desc": "Відтворення звукового сповіщення для нагадувань календаря",
"invitation_parsing": "Проаналізуйте запрошення електронною поштою",
"invitation_parsing_desc": "Виявляти запрошення календаря у вкладеннях електронної пошти та показувати дії календаря"
},
"push": {
"confirm_disable_message": "Цей пристрій перестане отримувати сповіщення, коли сайт закритий.",
"confirm_disable_title": "Вимкнути фонові сповіщення?",
"description": "Отримуйте системні сповіщення про нові листи, коли цей сайт закритий. Доставляється через push-реле Bulwark; реле ніколи не бачить вміст листів.",
"disable": "Вимкнути",
"enable": "Увімкнути",
"ios_hint": "На iOS спочатку встановіть сайт на головний екран – Safari доставляє Web Push лише встановленим PWA.",
"reenable": "Перереєструвати",
"relay_desc": "Типово використовується розміщене реле Bulwark. Змінюйте лише, якщо хостите самостійно.",
"relay_label": "Push-реле",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Активно на цьому пристрої",
"status_busy": "Виконується…",
"status_inactive": "Не ввімкнено на цьому пристрої",
"status_unsupported": "Цей браузер не підтримує Web Push",
"title": "Фонові сповіщення"
}
},
"language_region": {
@@ -889,6 +911,13 @@
"label": "Автоматичний вибір адреси для відповіді",
"description": "Під час відповіді автоматично змінюйте адресу відправника на особу, яка спочатку отримала повідомлення"
},
"sub_address_delimiter": {
"label": "Розділювач під-адреси",
"description": "Символ, який відокремлює ім'я користувача від мітки під-адреси. Використовуйте розділювач, налаштований на вашому поштовому сервері (напр. user{delimiter}tag@domain.com).",
"option": "{delimiter} (user{delimiter}tag@domain.com)",
"custom": "Власний…",
"custom_input_label": "Власний символ-розділювач"
},
"attachment_click_action": {
"label": "Вкладення Натисніть Дія",
"description": "Виберіть, чи клацання вкладеного файлу попередньо переглядає його чи негайно завантажує",
@@ -1333,7 +1362,9 @@
"no_address_books": "Адресних книг не знайдено",
"categories_title": "Категорії",
"categories_description": "Перейменувати категорії контактів",
"no_categories": "Категорії не знайдено"
"no_categories": "Категорії не знайдено",
"group_by_letter_description": "Показувати алфавітні заголовки розділів у списку контактів",
"group_by_letter_label": "Групувати за першою літерою"
},
"filters": {
"title": "Фільтри електронної пошти",
@@ -1732,7 +1763,7 @@
"use_address": "Використовуйте цю адресу",
"invalid_tag": "Тег має бути лише буквено-цифровим і тире",
"tag_too_long": "Тег має містити 30 символів або менше",
"help_text": "Електронні листи, надіслані на user+tag@domain.com, надходитимуть до вашої скриньки",
"help_text": "Електронні листи, надіслані на user{delimiter}tag@domain.com, надходитимуть до вашої скриньки",
"validation": {
"empty": "Тег не може бути порожнім",
"too_long": "Тег має містити не більше {max} символів",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "завтра",
"export_ics": "Експортувати як .ics",
"copy_title": "Скопіювати назву",
"copy_link": "Скопіювати посилання зустрічі"
"copy_link": "Скопіювати посилання зустрічі",
"go_to_today": "Перейти до сьогодні",
"new_all_day_event": "Нова подія на весь день",
"new_event": "Нова подія",
"new_task": "Нове завдання"
},
"detail": {
"add_note": "Додати примітку...",
@@ -2428,6 +2463,20 @@
"due_today": "Сьогодні",
"due_tomorrow": "завтра",
"overdue": "Прострочена"
},
"months": {
"jan": "січ.",
"feb": "лют.",
"mar": "бер.",
"apr": "квіт.",
"may": "трав.",
"jun": "черв.",
"jul": "лип.",
"aug": "серп.",
"sep": "верес.",
"oct": "жовт.",
"nov": "лист.",
"dec": "груд."
}
},
"advanced_search": {
+54 -5
View File
@@ -426,7 +426,9 @@
"cancel_info": "组织者已取消此活动。",
"event_updated": "更新 #{sequence}",
"event_status_tentative": "暂定",
"event_status_cancelled": "已取消"
"event_status_cancelled": "已取消",
"collapse": "隐藏详情",
"expand": "显示详情"
},
"send": "发送",
"more": "更多"
@@ -508,7 +510,10 @@
"message": "您的消息中提到了“{keyword}”,但未附加任何文件。仍然发送吗?",
"send_anyway": "仍然发送",
"back": "返回编辑"
}
},
"add_link": "添加链接",
"link_url_prompt": "输入 URL",
"sending": "发送中..."
},
"confirm_dialog": {
"confirm": "确认",
@@ -801,6 +806,23 @@
"sound_desc": "播放日历提醒的音频提醒",
"invitation_parsing": "解析邮件邀请",
"invitation_parsing_desc": "检测邮件附件中的日历邀请并显示日历操作"
},
"push": {
"confirm_disable_message": "当网站关闭时,此设备将停止接收提醒。",
"confirm_disable_title": "禁用后台通知?",
"description": "在此网站关闭时接收新邮件的系统通知。通过 Bulwark 推送中继传送;中继永远不会看到邮件内容。",
"disable": "禁用",
"enable": "启用",
"ios_hint": "在 iOS 上,请先将网站安装到主屏幕 – Safari 仅向已安装的 PWA 发送 Web Push。",
"reenable": "重新注册",
"relay_desc": "默认使用托管的 Bulwark 中继。仅当您自托管时才更改。",
"relay_label": "推送中继",
"relay_placeholder": "https://notifications.relay.example.com",
"status_active": "在此设备上活动",
"status_busy": "工作中…",
"status_inactive": "在此设备上未启用",
"status_unsupported": "此浏览器不支持 Web Push",
"title": "后台通知"
}
},
"language_region": {
@@ -889,6 +911,13 @@
"label": "自动选择回复地址",
"description": "回复时自动将发件人地址切换为最初收到该邮件的身份"
},
"sub_address_delimiter": {
"label": "子地址分隔符",
"description": "用于分隔用户名和子地址标签的字符。请选择与您的邮件服务器一致的分隔符(例如 user{delimiter}tag@domain.com)。",
"option": "{delimiter} (user{delimiter}tag@domain.com)",
"custom": "自定义…",
"custom_input_label": "自定义分隔字符"
},
"attachment_click_action": {
"label": "附件单击操作",
"description": "选择点击附件时是预览还是直接下载",
@@ -1333,7 +1362,9 @@
"no_address_books": "未找到地址簿",
"categories_title": "类别",
"categories_description": "重命名联系人类别",
"no_categories": "未找到类别"
"no_categories": "未找到类别",
"group_by_letter_description": "在联系人列表中显示按字母顺序排列的分节标题",
"group_by_letter_label": "按首字母分组"
},
"filters": {
"title": "邮件过滤器",
@@ -1732,7 +1763,7 @@
"use_address": "使用此地址",
"invalid_tag": "标签只能是字母数字和破折号",
"tag_too_long": "标签不得超过 30 个字符",
"help_text": "发送到 user+tag@domain.com 的邮件将送达您的收件箱",
"help_text": "发送到 user{delimiter}tag@domain.com 的邮件将送达您的收件箱",
"validation": {
"empty": "标签不能为空",
"too_long": "标签不得超过 {max} 个字符",
@@ -2129,7 +2160,11 @@
"tomorrow_header": "明天",
"export_ics": "导出为 .ics",
"copy_title": "复制标题",
"copy_link": "复制会议链接"
"copy_link": "复制会议链接",
"go_to_today": "前往今天",
"new_all_day_event": "新建全天事件",
"new_event": "新建事件",
"new_task": "新建任务"
},
"detail": {
"add_note": "添加注释...",
@@ -2428,6 +2463,20 @@
"due_today": "今天",
"due_tomorrow": "明天",
"overdue": "逾期"
},
"months": {
"jan": "1月",
"feb": "2月",
"mar": "3月",
"apr": "4月",
"may": "5月",
"jun": "6月",
"jul": "7月",
"aug": "8月",
"sep": "9月",
"oct": "10月",
"nov": "11月",
"dec": "12月"
}
},
"advanced_search": {
+59 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-webmail",
"version": "1.5.3",
"version": "1.5.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-webmail",
"version": "1.5.3",
"version": "1.5.4",
"license": "AGPL-3.0-only",
"dependencies": {
"@tanstack/react-virtual": "^3.13.24",
@@ -14,6 +14,10 @@
"@tiptap/extension-image": "^3.22.4",
"@tiptap/extension-link": "^3.22.4",
"@tiptap/extension-placeholder": "^3.22.4",
"@tiptap/extension-table": "^3.22.4",
"@tiptap/extension-table-cell": "^3.22.4",
"@tiptap/extension-table-header": "^3.22.4",
"@tiptap/extension-table-row": "^3.22.4",
"@tiptap/extension-text-align": "^3.22.4",
"@tiptap/extension-text-style": "^3.22.4",
"@tiptap/extension-underline": "^3.22.4",
@@ -3639,6 +3643,59 @@
"@tiptap/core": "3.22.4"
}
},
"node_modules/@tiptap/extension-table": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.22.4.tgz",
"integrity": "sha512-kjvLv3Z4JI+1tLDqZKa+bKU8VcxY+ZOyMCKWQA7wYmy8nKWkLJ60W+xy8AcXXpHB2goCIgSFLhsTyswx0GXH4w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.4",
"@tiptap/pm": "3.22.4"
}
},
"node_modules/@tiptap/extension-table-cell": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.22.4.tgz",
"integrity": "sha512-uvFegCc1UQYK2nfIV2sIHg+hzLIMroJJm00XomzBgC1w/eSO7Ui8APiDh/baBcTPpCSU3SLiQLTgx7AU7oE3pg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-table": "3.22.4"
}
},
"node_modules/@tiptap/extension-table-header": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.22.4.tgz",
"integrity": "sha512-V4kLLWeRdc/I+IXiXZZhLAjsaHHiJWuLXTuOtZRDrCxQUiFLi4AgNg1DPQ09JAANkEWDhXq3x6BoUXaFwumbEw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-table": "3.22.4"
}
},
"node_modules/@tiptap/extension-table-row": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.22.4.tgz",
"integrity": "sha512-9tdS6jgS6DqUu5TpEmNrRoo/DL5Xam0PyrQaUEXUC+ssci+bMRCJ8PAWMcunNsI9NKf/Tb3wYrv6hGFChaT9uA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-table": "3.22.4"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.4.tgz",
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-webmail",
"version": "1.5.3",
"version": "1.5.4",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail <bulwark@rbm.systems>",
"license": "AGPL-3.0-only",
@@ -37,6 +37,10 @@
"@tiptap/extension-image": "^3.22.4",
"@tiptap/extension-link": "^3.22.4",
"@tiptap/extension-placeholder": "^3.22.4",
"@tiptap/extension-table": "^3.22.4",
"@tiptap/extension-table-cell": "^3.22.4",
"@tiptap/extension-table-header": "^3.22.4",
"@tiptap/extension-table-row": "^3.22.4",
"@tiptap/extension-text-align": "^3.22.4",
"@tiptap/extension-text-style": "^3.22.4",
"@tiptap/extension-underline": "^3.22.4",
+1 -1
View File
@@ -23,7 +23,7 @@ export async function proxy(request: NextRequest) {
? `'self' 'nonce-${nonce}' 'unsafe-eval' blob:`
: `'self' 'nonce-${nonce}' blob:`;
const connectSrc = isDev ? `'self' https: ws: wss:` : `'self' https:`;
const connectSrc = isDev ? `'self' http: https: ws: wss:` : `'self' https:`;
const frameAncestors = process.env.ALLOWED_FRAME_ANCESTORS?.trim() || "'none'";
+136 -4
View File
@@ -1,8 +1,14 @@
/* eslint-disable no-undef */
// Minimal service worker satisfies the PWA installability requirement
// without caching any assets. All requests fall through to the network,
// so there is no risk of serving stale chunks after a deployment.
// Bulwark service worker.
//
// This SW does two jobs:
// 1. Satisfy the PWA installability requirement (network-only fetch handler,
// no caching - so we never serve stale chunks after a deployment).
// 2. Receive Web Push wake-up pings from the relay and turn them into
// enriched system notifications. Mirrors the React Native FCM headless
// task: relay sends only a state-change ping, the client fetches the
// newest unread email itself so the relay never sees mail content.
self.addEventListener("install", () => {
self.skipWaiting();
@@ -12,5 +18,131 @@ self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
// Network-only fetch handler no caching.
self.addEventListener("fetch", () => {});
self.addEventListener("push", (event) => {
event.waitUntil(handlePush(event));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(handleNotificationClick(event));
});
async function handlePush(event) {
let payload = null;
try {
payload = event.data ? event.data.json() : null;
} catch (_) {
payload = null;
}
const accountLabel = (payload && typeof payload.accountLabel === "string")
? payload.accountLabel
: "";
// Best effort: ask the webmail to look up the latest unread email so we can
// build a useful notification. If the request fails (offline, session
// expired, server down) we fall back to a generic "New mail" so the user
// still sees something.
let preview = null;
let previewOk = false;
try {
const res = await fetch("/api/push/preview", {
credentials: "include",
cache: "no-store",
});
if (res.ok) {
preview = await res.json();
previewOk = true;
}
} catch (_) {
preview = null;
}
const email = preview && preview.email ? preview.email : null;
const unreadTotal = preview && typeof preview.unreadTotal === "number"
? preview.unreadTotal
: 0;
// Push subscription is scoped to EmailDelivery, but stragglers from the
// older broader-types subscription, marking-as-read races and verification
// pings can still wake us with no actual unread mail. When the preview API
// succeeded and reports zero unread, stay silent. When the preview API
// failed (network/auth/server down) we cannot tell, so fall through to the
// generic "New mail" toast rather than miss a real delivery.
if (previewOk && !email && unreadTotal === 0) {
return;
}
let title;
let body;
let tag = "bulwark-mail";
let data = { kind: "mail-list" };
if (email) {
const sender = email.from && email.from[0];
const senderName = (sender && sender.name) || (sender && sender.email) || "New mail";
title = senderName + (accountLabel ? ` (${accountLabel})` : "");
body = email.subject || email.preview || "(no subject)";
tag = "bulwark-mail:" + email.id;
data = {
kind: "email",
emailId: email.id,
threadId: email.threadId,
};
} else {
title = accountLabel ? `New mail (${accountLabel})` : "New mail";
body = unreadTotal > 1 ? `${unreadTotal} unread messages` : "You have new mail";
}
await self.registration.showNotification(title, {
body,
tag,
icon: "/icon-192x192.png",
badge: "/icon-192x192.png",
data,
renotify: true,
});
}
async function handleNotificationClick(event) {
const data = event.notification.data || {};
const targetUrl = buildClickUrl(data);
const allClients = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
for (const client of allClients) {
// Reuse an existing tab whenever possible - users on desktop browsers
// get annoyed when each notification opens a fresh window.
if ("focus" in client) {
try {
if ("navigate" in client && targetUrl) {
await client.navigate(targetUrl);
}
return client.focus();
} catch (_) {
// navigate() can reject for cross-origin or detached clients - fall
// through and open a new window below.
}
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(targetUrl || "/");
}
}
function buildClickUrl(data) {
if (!data) return "/";
if (data.kind === "email" && data.emailId) {
return `/?email=${encodeURIComponent(data.emailId)}`;
}
// Generic "New mail" toast (preview API failed or returned no email): land
// the user on the latest unread message in their Inbox rather than just the
// app shell, so the click still feels purposeful.
return "/?openLatestUnread=1";
}
+10 -4
View File
@@ -1209,6 +1209,15 @@ export const useAuthStore = create<AuthState>()(
for (const account of accounts) {
if (clients.has(account.id)) continue; // Already connected
// Basic auth without rememberMe leaves nothing to restore — the
// user logged in without persisting credentials. Evict silently
// so the login screen is shown without flagging a fake error.
if (account.authMode === 'basic' && !account.rememberMe) {
evictAccount(account.id);
accountStore.removeAccount(account.id);
continue;
}
try {
if (account.authMode === 'oauth') {
const res = await apiFetch(`/api/auth/token?slot=${account.cookieSlot}`, { method: 'PUT' });
@@ -1225,7 +1234,7 @@ export const useAuthStore = create<AuthState>()(
} else {
throw new Error(`Token refresh failed: ${res.status}`);
}
} else if (account.authMode === 'basic' && account.rememberMe) {
} else {
const res = await apiFetch(`/api/auth/session?slot=${account.cookieSlot}`, { method: 'PUT' });
if (res.ok) {
const { serverUrl, username, password } = await res.json();
@@ -1238,9 +1247,6 @@ export const useAuthStore = create<AuthState>()(
} else {
throw new Error(`Session cookie missing: ${res.status}`);
}
} else {
// Basic auth without rememberMe - can't restore
throw new Error('No saved session');
}
} catch (err) {
debug.error(`Failed to restore account ${account.id}:`, err);
+14 -10
View File
@@ -72,7 +72,7 @@ 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 }>) => 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[]) => Promise<void>;
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>;
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
@@ -507,10 +507,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments) => {
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references) => {
set({ isLoading: true, error: null });
try {
await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments);
await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references);
// Refresh handled by UI layer for immediate feedback
set({ isLoading: false });
} catch (error) {
@@ -1530,13 +1530,17 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const currentEmails = get().emails;
// Check if there are new emails by comparing the first email ID
const currentFirstEmailId = currentEmails[0]?.id;
const newFirstEmailId = result.emails[0]?.id;
// If the first email changed, we have a new email - trigger notification
if (currentFirstEmailId !== newFirstEmailId && result.emails[0]) {
get().handleNewEmailNotification(result.emails[0]);
// Only notify for genuinely new incoming mail in the Inbox.
// Without these guards the toast/sound also fires when sending,
// saving drafts, or moving/deleting the top message in any mailbox,
// because all of those change the first-email id of the current view.
const newFirst = result.emails[0];
if (
newFirst &&
mailbox?.role === 'inbox' &&
!currentEmails.some(e => e.id === newFirst.id)
) {
get().handleNewEmailNotification(newFirst);
}
// Merge the refreshed first page with the existing loaded emails.
+10
View File
@@ -4,6 +4,10 @@ import { useThemeStore } from './theme-store';
import { useLocaleStore } from './locale-store';
import type { NotificationSoundChoice } from '@/lib/notification-sound';
import { apiFetch } from '@/lib/browser-navigation';
import {
DEFAULT_SUB_ADDRESS_DELIMITER,
isValidSubAddressDelimiter,
} from '@/lib/sub-addressing';
// Use console directly to avoid circular dependency with lib/debug.ts
// (debug.ts imports useSettingsStore for debugMode check)
@@ -138,6 +142,7 @@ interface SettingsState {
defaultReplyMode: ReplyMode;
autoSelectReplyIdentity: boolean;
plainTextMode: boolean; // Send plain text only (no rich text editor)
subAddressDelimiter: string; // Character separating user from tag (e.g. "user+tag@")
// Privacy & Security
sessionTimeout: number; // minutes (0 = never)
@@ -286,6 +291,7 @@ const DEFAULT_SETTINGS = {
defaultReplyMode: 'reply' as ReplyMode,
autoSelectReplyIdentity: false,
plainTextMode: false,
subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER,
// Privacy & Security
sessionTimeout: 0, // Never
@@ -456,6 +462,7 @@ export const useSettingsStore = create<SettingsState>()(
defaultReplyMode: state.defaultReplyMode,
autoSelectReplyIdentity: state.autoSelectReplyIdentity,
plainTextMode: state.plainTextMode,
subAddressDelimiter: state.subAddressDelimiter,
sessionTimeout: state.sessionTimeout,
emailNotificationsEnabled: state.emailNotificationsEnabled,
emailNotificationSound: state.emailNotificationSound,
@@ -508,6 +515,9 @@ export const useSettingsStore = create<SettingsState>()(
// Apply settings
Object.keys(settings).forEach((key) => {
if (key in DEFAULT_SETTINGS) {
if (key === 'subAddressDelimiter' && !isValidSubAddressDelimiter(settings[key])) {
return;
}
set({ [key]: settings[key] });
}
});