feat: expand internationalization and add identity management

This release significantly expands internationalization support and adds comprehensive identity management features.

Internationalization (i18n):
- Add 5 new languages: Spanish, Italian, German, Dutch, Portuguese
- Expand from 3 to 8 total supported languages
- Redesign language switcher for better scalability (dropdown UI)
- Complete translations for all features across all languages

Identity Management:
- Multiple sender identities with per-identity signatures
- Sub-addressing support (user+tag@domain.com)
- Context-aware tag suggestions for sub-addresses
- Identity badges in email viewer and list
- Full CRUD operations for managing identities

Newsletter Management:
- RFC 2369 List-Unsubscribe support (one-click unsubscribe)
- HTTP and mailto unsubscribe methods
- Security validation prevents XSS attacks
- Two-step confirmation with persistent dismissal

Security & Accessibility:
- Dark mode email readability (intelligent color transformation)
- WCAG 2.0 Level AA color contrast compliance
- Comprehensive XSS prevention with validation utilities
- Unit test coverage for security-critical code (57 validation tests)

Testing:
- Add unit tests for validation utilities
- Add unit tests for email sanitization
- Add unit tests for color transformation
- Full test coverage for XSS attack vectors
This commit is contained in:
Matthieu MALVACHE
2026-01-08 22:15:32 +01:00
committed by Matthieu MALVACHE
parent 0d68851b63
commit 5d273e2109
49 changed files with 11018 additions and 432 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server*
- Shared folder support with proper permissions - Shared folder support with proper permissions
### Internationalization ### Internationalization
- English and French language support - 8 language support: English, French, Japanese, Spanish, Italian, German, Dutch, Portuguese
- Automatic browser language detection - Automatic browser language detection
- Persistent language preference - Persistent language preference
+25 -5
View File
@@ -70,14 +70,37 @@ This document tracks the development status and planned features for JMAP Webmai
### Internationalization ### Internationalization
- [x] English language support - [x] English language support
- [x] French language support - [x] French language support
- [x] Japanese language support
- [x] Spanish language support
- [x] Italian language support
- [x] German language support
- [x] Dutch language support
- [x] Portuguese language support
- [x] Automatic browser language detection - [x] Automatic browser language detection
- [x] Language preference persistence - [x] Language preference persistence
### Security ### Security & Accessibility
- [x] External content blocked by default - [x] External content blocked by default
- [x] HTML sanitization with DOMPurify - [x] HTML sanitization with DOMPurify
- [x] User control for loading external content - [x] User control for loading external content
- [x] Trusted senders list for automatic image loading - [x] Trusted senders list for automatic image loading
- [x] Dark mode email readability (intelligent color transformation)
- [x] WCAG 2.0 Level AA color contrast compliance
- [x] Newsletter unsubscribe support (RFC 2369)
- [x] XSS attack prevention with comprehensive validation
### Identity Management
- [x] Multiple sender identities (name, email, signature)
- [x] Sub-addressing support (user+tag@domain.com)
- [x] Per-identity signatures
- [x] Identity badges in email viewer and list
- [x] Tag suggestions based on context
### Testing
- [x] Unit tests for validation utilities (57 tests)
- [x] Unit tests for email sanitization
- [x] Unit tests for color transformation
- [x] XSS attack vector testing
### Deployment ### Deployment
- [x] Runtime environment variables (Docker-friendly configuration) - [x] Runtime environment variables (Docker-friendly configuration)
@@ -98,9 +121,7 @@ This document tracks the development status and planned features for JMAP Webmai
- [ ] Email filters and rules - [ ] Email filters and rules
- [ ] Calendar integration (JMAP Calendars) - [ ] Calendar integration (JMAP Calendars)
- [ ] Email templates - [ ] Email templates
- [ ] Signature management
- [ ] Vacation responder settings - [ ] Vacation responder settings
- [ ] Email aliases support
- [ ] Advanced search with filters - [ ] Advanced search with filters
- [ ] Email encryption (PGP/GPG) - [ ] Email encryption (PGP/GPG)
@@ -111,8 +132,7 @@ This document tracks the development status and planned features for JMAP Webmai
- [ ] Service worker for offline support - [ ] Service worker for offline support
- [ ] Lazy loading for attachments - [ ] Lazy loading for attachments
### Testing ### Testing (Remaining)
- [ ] Unit tests for utilities
- [ ] Component tests - [ ] Component tests
- [ ] E2E tests with Playwright - [ ] E2E tests with Playwright
- [ ] Accessibility testing - [ ] Accessibility testing
+78 -1
View File
@@ -75,6 +75,8 @@ export default function Home() {
setPushConnected, setPushConnected,
handleStateChange, handleStateChange,
clearNewEmailNotification, clearNewEmailNotification,
markAsSpam,
undoSpam,
} = useEmailStore(); } = useEmailStore();
// Play notification sound for new emails // Play notification sound for new emails
@@ -157,6 +159,18 @@ export default function Home() {
await markAsRead(client, selectedEmail.id, true); await markAsRead(client, selectedEmail.id, true);
} }
}, },
onToggleSpam: () => {
if (selectedEmail) {
// Check if we're in junk folder
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isInJunk = currentMailbox?.role === 'junk';
if (isInJunk) {
handleUndoSpam();
} else {
handleMarkAsSpam();
}
}
},
onCompose: () => { onCompose: () => {
setComposerMode('compose'); setComposerMode('compose');
setShowComposer(true); setShowComposer(true);
@@ -374,8 +388,11 @@ export default function Home() {
if (!client) return; if (!client) return;
try { try {
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.draftId, data.fromEmail, data.identityId); await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId);
setShowComposer(false); setShowComposer(false);
// Refresh the current mailbox to update the UI
await fetchEmails(client, selectedMailbox);
} catch (error) { } catch (error) {
console.error("Failed to send email:", error); console.error("Failed to send email:", error);
} }
@@ -442,6 +459,55 @@ export default function Home() {
} }
}; };
const handleMarkAsSpam = async () => {
if (!client || !selectedEmail) return;
const emailId = selectedEmail.id;
try {
await markAsSpam(client, emailId);
const toastInstance = (await import('sonner')).toast;
toastInstance.success(t('email_viewer.spam.toast_success'), {
action: {
label: t('email_viewer.spam.toast_undo'),
onClick: async () => {
try {
await undoSpam(client, emailId);
toastInstance.success(t('notifications.email_moved'));
} catch (_error) {
console.error("Failed to undo spam:", _error);
toastInstance.error(t('email_viewer.spam.error'));
}
},
},
duration: 5000,
});
} catch (_error) {
console.error("Failed to mark as spam:", _error);
const toastInstance = (await import('sonner')).toast;
toastInstance.error(t('email_viewer.spam.error'));
}
};
const handleUndoSpam = async () => {
if (!client || !selectedEmail) return;
try {
await undoSpam(client, selectedEmail.id);
const toastInstance = (await import('sonner')).toast;
toastInstance.success(t('email_viewer.spam.toast_not_spam_success'));
// Deselect email after moving it out of junk
selectEmail(null);
} catch (_error) {
console.error("Failed to restore email:", _error);
const toastInstance = (await import('sonner')).toast;
toastInstance.error(t('email_viewer.spam.error_not_spam'));
}
};
const handleSetColorTag = async (emailId: string, color: string | null) => { const handleSetColorTag = async (emailId: string, color: string | null) => {
if (!client) return; if (!client) return;
@@ -766,6 +832,14 @@ export default function Home() {
await moveToMailbox(client, emailId, mailboxId); await moveToMailbox(client, emailId, mailboxId);
} }
}} }}
onMarkAsSpam={async (email) => {
selectEmail(email);
await handleMarkAsSpam();
}}
onUndoSpam={async (email) => {
selectEmail(email);
await handleUndoSpam();
}}
className="flex-1" className="flex-1"
/> />
</ErrorBoundary> </ErrorBoundary>
@@ -820,6 +894,8 @@ export default function Home() {
onArchive={handleArchive} onArchive={handleArchive}
onToggleStar={handleToggleStar} onToggleStar={handleToggleStar}
onSetColorTag={handleSetColorTag} onSetColorTag={handleSetColorTag}
onMarkAsSpam={handleMarkAsSpam}
onUndoSpam={handleUndoSpam}
onMarkAsRead={async (emailId, read) => { onMarkAsRead={async (emailId, read) => {
if (client) { if (client) {
await markAsRead(client, emailId, read); await markAsRead(client, emailId, read);
@@ -833,6 +909,7 @@ export default function Home() {
}} }}
currentUserEmail={client?.["username"]} currentUserEmail={client?.["username"]}
currentUserName={client?.["username"]?.split("@")[0]} currentUserName={client?.["username"]?.split("@")[0]}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
className={isMobile ? "flex-1" : undefined} className={isMobile ? "flex-1" : undefined}
/> />
</ErrorBoundary> </ErrorBoundary>
+4 -1
View File
@@ -8,10 +8,11 @@ import { Button } from '@/components/ui/button';
import { AppearanceSettings } from '@/components/settings/appearance-settings'; import { AppearanceSettings } from '@/components/settings/appearance-settings';
import { EmailSettings } from '@/components/settings/email-settings'; import { EmailSettings } from '@/components/settings/email-settings';
import { AccountSettings } from '@/components/settings/account-settings'; import { AccountSettings } from '@/components/settings/account-settings';
import { IdentitySettings } from '@/components/settings/identity-settings';
import { AdvancedSettings } from '@/components/settings/advanced-settings'; import { AdvancedSettings } from '@/components/settings/advanced-settings';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
type Tab = 'appearance' | 'email' | 'account' | 'advanced'; type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'advanced';
export default function SettingsPage() { export default function SettingsPage() {
const router = useRouter(); const router = useRouter();
@@ -22,6 +23,7 @@ export default function SettingsPage() {
{ id: 'appearance', label: t('tabs.appearance') }, { id: 'appearance', label: t('tabs.appearance') },
{ id: 'email', label: t('tabs.email') }, { id: 'email', label: t('tabs.email') },
{ id: 'account', label: t('tabs.account') }, { id: 'account', label: t('tabs.account') },
{ id: 'identities', label: t('tabs.identities') },
{ id: 'advanced', label: t('tabs.advanced') }, { id: 'advanced', label: t('tabs.advanced') },
]; ];
@@ -79,6 +81,7 @@ export default function SettingsPage() {
{activeTab === 'appearance' && <AppearanceSettings />} {activeTab === 'appearance' && <AppearanceSettings />}
{activeTab === 'email' && <EmailSettings />} {activeTab === 'email' && <EmailSettings />}
{activeTab === 'account' && <AccountSettings />} {activeTab === 'account' && <AccountSettings />}
{activeTab === 'identities' && <IdentitySettings />}
{activeTab === 'advanced' && <AdvancedSettings />} {activeTab === 'advanced' && <AdvancedSettings />}
</div> </div>
</div> </div>
+9 -2
View File
@@ -5,8 +5,15 @@ import { AlertTriangle, RefreshCw } from "lucide-react";
/** /**
* Global error boundary for the root layout. * Global error boundary for the root layout.
* Note: This component cannot use translations since it's outside providers. *
* It must render its own <html> and <body> tags as it replaces the root layout. * IMPORTANT: Strings in this file CANNOT be translated.
* This global error boundary renders outside the root layout and has no access
* to providers (including next-intl). This is a Next.js limitation for
* catastrophic error handling. These English strings only appear during
* critical failures when the entire app crashes.
*
* The component must render its own <html> and <body> tags as it replaces
* the root layout entirely.
*/ */
export default function GlobalError({ export default function GlobalError({
error, error,
+2 -2
View File
@@ -139,7 +139,7 @@ body {
border-left: 3px solid #d1d5db; border-left: 3px solid #d1d5db;
padding-left: 1rem; padding-left: 1rem;
margin: 1rem 0; margin: 1rem 0;
color: #6b7280; color: #4b5563;
font-style: italic; font-style: italic;
} }
@@ -281,7 +281,7 @@ body {
border-left: 3px solid #d1d5db; border-left: 3px solid #d1d5db;
padding-left: 1rem; padding-left: 1rem;
margin: 1rem 0; margin: 1rem 0;
color: #6b7280; color: #4b5563;
opacity: 0.8; opacity: 0.8;
} }
+84 -25
View File
@@ -7,6 +7,8 @@ import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle } from "lucide-react"; import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
import { generateSubAddress } from "@/lib/sub-addressing";
interface EmailComposerProps { interface EmailComposerProps {
onSend?: (data: { onSend?: (data: {
@@ -42,6 +44,7 @@ export function EmailComposer({
replyTo replyTo
}: EmailComposerProps) { }: EmailComposerProps) {
const t = useTranslations('email_composer'); const t = useTranslations('email_composer');
const tCommon = useTranslations('common');
// Initialize with reply/forward data if provided // Initialize with reply/forward data if provided
const getInitialTo = () => { const getInitialTo = () => {
@@ -64,9 +67,11 @@ export function EmailComposer({
const getInitialSubject = () => { const getInitialSubject = () => {
if (!replyTo?.subject) return ""; if (!replyTo?.subject) return "";
if (mode === 'forward') { if (mode === 'forward') {
return `Fwd: ${replyTo.subject.replace(/^(Fwd:\s*)+/i, '')}`; const fwdPrefix = t('prefix.forward');
return `${fwdPrefix} ${replyTo.subject.replace(/^(Fwd:\s*|Tr:\s*)+/i, '')}`;
} else if (mode === 'reply' || mode === 'replyAll') { } else if (mode === 'reply' || mode === 'replyAll') {
return `Re: ${replyTo.subject.replace(/^(Re:\s*)+/i, '')}`; const rePrefix = t('prefix.reply');
return `${rePrefix} ${replyTo.subject.replace(/^(Re:\s*)+/i, '')}`;
} }
return ""; return "";
}; };
@@ -76,7 +81,7 @@ export function EmailComposer({
const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : ""; const date = replyTo.receivedAt ? new Date(replyTo.receivedAt).toLocaleString() : "";
const from = replyTo.from?.[0]; const from = replyTo.from?.[0];
const fromStr = from ? `${from.name || from.email}` : "Unknown"; const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
if (mode === 'forward') { if (mode === 'forward') {
return `\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`; return `\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`;
@@ -100,6 +105,7 @@ export function EmailComposer({
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean }>>([]); const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean }>>([]);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(null); const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(null);
const [subAddressTag, setSubAddressTag] = useState<string>('');
const { client, identities, primaryIdentity } = useAuthStore(); const { client, identities, primaryIdentity } = useAuthStore();
@@ -176,7 +182,7 @@ export function EmailComposer({
})); }));
// Create a hash of current data to compare with last saved // Create a hash of current data to compare with last saved
const currentData = JSON.stringify({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, body, attachments: uploadedAttachments }); const currentData = JSON.stringify({ to: toAddresses, cc: ccAddresses, bcc: bccAddresses, subject, body, attachments: uploadedAttachments, identityId: selectedIdentityId, subAddressTag });
// Only save if data has changed // Only save if data has changed
if (currentData === lastSavedDataRef.current) { if (currentData === lastSavedDataRef.current) {
@@ -185,13 +191,27 @@ export function EmailComposer({
setSaveStatus('saving'); setSaveStatus('saving');
// Get the selected identity or primary identity
const currentIdentity = selectedIdentityId
? identities.find(id => id.id === selectedIdentityId)
: primaryIdentity;
// Generate sub-addressed email if tag is set
const fromEmail = currentIdentity?.email
? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag)
: currentIdentity.email
: undefined;
try { try {
const savedDraftId = await client.createDraft( const savedDraftId = await client.createDraft(
toAddresses, toAddresses,
subject || "(No subject)", subject || t('no_subject'),
body, body,
ccAddresses, ccAddresses,
bccAddresses, bccAddresses,
currentIdentity?.id,
fromEmail,
draftId || undefined, draftId || undefined,
uploadedAttachments uploadedAttachments
); );
@@ -263,6 +283,13 @@ export function EmailComposer({
? identities.find(id => id.id === selectedIdentityId) ? identities.find(id => id.id === selectedIdentityId)
: primaryIdentity; : primaryIdentity;
// Generate sub-addressed email if tag is set
const fromEmail = currentIdentity?.email
? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag)
: currentIdentity.email
: undefined;
onSend?.({ onSend?.({
to: toAddresses, to: toAddresses,
cc: ccAddresses, cc: ccAddresses,
@@ -270,7 +297,7 @@ export function EmailComposer({
subject, subject,
body, body,
draftId: finalDraftId || undefined, draftId: finalDraftId || undefined,
fromEmail: currentIdentity?.email, fromEmail,
identityId: currentIdentity?.id, identityId: currentIdentity?.id,
}); });
@@ -281,6 +308,7 @@ export function EmailComposer({
setSubject(""); setSubject("");
setBody(""); setBody("");
setDraftId(null); setDraftId(null);
setSubAddressTag("");
} }
}; };
@@ -341,25 +369,56 @@ export function EmailComposer({
{/* From field - show dropdown if multiple identities, otherwise display email */} {/* From field - show dropdown if multiple identities, otherwise display email */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-16">{t('from')}:</span> <span className="text-sm text-muted-foreground w-16">{t('from')}:</span>
{identities.length > 1 ? ( <div className="flex-1 flex items-center gap-1">
<select {identities.length > 1 ? (
value={selectedIdentityId || primaryIdentity?.id || ''} <select
onChange={(e) => setSelectedIdentityId(e.target.value)} value={selectedIdentityId || primaryIdentity?.id || ''}
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors" onChange={(e) => setSelectedIdentityId(e.target.value)}
> className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors"
{identities.map((identity) => ( >
<option key={identity.id} value={identity.id}> {identities.map((identity) => (
{identity.name ? `${identity.name} <${identity.email}>` : identity.email} <option key={identity.id} value={identity.id}>
</option> {identity.name ? `${identity.name} <${identity.email}>` : identity.email}
))} </option>
</select> ))}
) : ( </select>
<span className="text-sm text-foreground"> ) : (
{primaryIdentity?.name <span className="text-sm text-foreground flex-1">
? `${primaryIdentity.name} <${primaryIdentity.email}>` {subAddressTag ? (
: primaryIdentity?.email || ''} <span className="font-mono">
</span> {generateSubAddress(primaryIdentity?.email || '', subAddressTag)}
)} </span>
) : (
<>
{primaryIdentity?.name
? `${primaryIdentity.name} <${primaryIdentity.email}>`
: primaryIdentity?.email || ''}
</>
)}
</span>
)}
<SubAddressHelper
baseEmail={
(selectedIdentityId
? identities.find(id => id.id === selectedIdentityId)?.email
: primaryIdentity?.email) || ''
}
recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)}
onSelectTag={setSubAddressTag}
/>
{subAddressTag && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setSubAddressTag('')}
className="h-6 px-2 text-xs"
title={t('remove_sub_address')}
>
<X className="w-3 h-3" />
</Button>
)}
</div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+42 -11
View File
@@ -25,6 +25,8 @@ import {
Send, Send,
File, File,
Folder, Folder,
ShieldAlert,
ShieldCheck,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -41,6 +43,7 @@ interface EmailContextMenuProps {
menuRef: React.RefObject<HTMLDivElement | null>; menuRef: React.RefObject<HTMLDivElement | null>;
mailboxes: Mailbox[]; mailboxes: Mailbox[];
selectedMailbox: string; selectedMailbox: string;
currentMailboxRole?: string;
isMultiSelect?: boolean; isMultiSelect?: boolean;
selectedCount?: number; selectedCount?: number;
// Single email actions // Single email actions
@@ -53,23 +56,16 @@ interface EmailContextMenuProps {
onArchive?: () => void; onArchive?: () => void;
onSetColorTag?: (color: string | null) => void; onSetColorTag?: (color: string | null) => void;
onMoveToMailbox?: (mailboxId: string) => void; onMoveToMailbox?: (mailboxId: string) => void;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
// Batch actions // Batch actions
onBatchMarkAsRead?: (read: boolean) => void; onBatchMarkAsRead?: (read: boolean) => void;
onBatchDelete?: () => void; onBatchDelete?: () => void;
onBatchMoveToMailbox?: (mailboxId: string) => void; onBatchMoveToMailbox?: (mailboxId: string) => void;
onBatchMarkAsSpam?: () => void;
onBatchUndoSpam?: () => void;
} }
// Color options for email tags
const colorOptions = [
{ name: "Red", value: "red", color: "bg-red-500" },
{ name: "Orange", value: "orange", color: "bg-orange-500" },
{ name: "Yellow", value: "yellow", color: "bg-yellow-500" },
{ name: "Green", value: "green", color: "bg-green-500" },
{ name: "Blue", value: "blue", color: "bg-blue-500" },
{ name: "Purple", value: "purple", color: "bg-purple-500" },
{ name: "Pink", value: "pink", color: "bg-pink-500" },
];
// Get mailbox icon based on role // Get mailbox icon based on role
const getMailboxIcon = (role?: string) => { const getMailboxIcon = (role?: string) => {
switch (role) { switch (role) {
@@ -107,6 +103,7 @@ export function EmailContextMenu({
menuRef, menuRef,
mailboxes, mailboxes,
selectedMailbox, selectedMailbox,
currentMailboxRole,
isMultiSelect = false, isMultiSelect = false,
selectedCount = 1, selectedCount = 1,
onReply, onReply,
@@ -118,15 +115,32 @@ export function EmailContextMenu({
onArchive, onArchive,
onSetColorTag, onSetColorTag,
onMoveToMailbox, onMoveToMailbox,
onMarkAsSpam,
onUndoSpam,
onBatchMarkAsRead, onBatchMarkAsRead,
onBatchDelete, onBatchDelete,
onBatchMoveToMailbox, onBatchMoveToMailbox,
onBatchMarkAsSpam,
onBatchUndoSpam,
}: EmailContextMenuProps) { }: EmailContextMenuProps) {
const t = useTranslations("context_menu"); const t = useTranslations("context_menu");
const tColor = useTranslations("email_viewer.color_tag");
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
const currentColor = getCurrentColor(email.keywords); const currentColor = getCurrentColor(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1; const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk';
// Color options for email tags (using translations)
const colorOptions = [
{ name: tColor("red"), value: "red", color: "bg-red-500" },
{ name: tColor("orange"), value: "orange", color: "bg-orange-500" },
{ name: tColor("yellow"), value: "yellow", color: "bg-yellow-500" },
{ name: tColor("green"), value: "green", color: "bg-green-500" },
{ name: tColor("blue"), value: "blue", color: "bg-blue-500" },
{ name: tColor("purple"), value: "purple", color: "bg-purple-500" },
{ name: tColor("pink"), value: "pink", color: "bg-pink-500" },
];
// Filter mailboxes for move-to submenu (exclude current, drafts, virtual nodes) // Filter mailboxes for move-to submenu (exclude current, drafts, virtual nodes)
const moveTargets = mailboxes.filter( const moveTargets = mailboxes.filter(
@@ -239,6 +253,23 @@ export function EmailContextMenu({
<ContextMenuSeparator /> <ContextMenuSeparator />
{/* Spam - contextual based on folder */}
<ContextMenuItem
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
onClick={() =>
handleAction(
showBatchActions
? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
: (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
)
}
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
destructive={!isInJunkFolder}
/>
<ContextMenuSeparator />
{/* Set color submenu - only for single email */} {/* Set color submenu - only for single email */}
{!showBatchActions && ( {!showBatchActions && (
<ContextMenuSubMenu icon={Palette} label={t("color_tag")}> <ContextMenuSubMenu icon={Palette} label={t("color_tag")}>
+142
View File
@@ -0,0 +1,142 @@
'use client';
import { useTranslations } from 'next-intl';
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';
interface EmailIdentityBadgeProps {
email: Email;
identities: Identity[];
compact?: boolean;
className?: string;
}
export function EmailIdentityBadge({
email,
identities,
compact = false,
className,
}: EmailIdentityBadgeProps) {
const t = useTranslations('identities.badge');
const fromAddress = email.from?.[0]?.email;
if (!fromAddress) return null;
// Parse the from address to check for sub-addressing
const parsedFrom = parseSubAddress(fromAddress);
// Find matching identity (email sent BY the user)
const matchingIdentity = identities.find(
(identity) => identity.email === fromAddress || identity.email === `${parsedFrom.baseUser}@${parsedFrom.domain}`
);
// Check if email was sent TO a sub-address (received email)
let receivedToTag: string | null = null;
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);
if (parsedTo.tag) {
// Check if this base email matches any of the user's identities
const matchingToIdentity = identities.find(
(identity) => identity.email === `${parsedTo.baseUser}@${parsedTo.domain}`
);
if (matchingToIdentity) {
receivedToTag = parsedTo.tag;
break;
}
}
}
}
// Determine which tag to display (sent or received)
const displayTag = matchingIdentity ? parsedFrom.tag : receivedToTag;
// Don't show badge if not from user's identity and not to user's sub-address
if (!matchingIdentity && !receivedToTag) return null;
if (compact) {
// Compact view for email list
if (displayTag) {
return (
<div
className={cn(
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs',
'bg-primary/10 text-primary',
className
)}
title={t('sub_address_tag', { tag: displayTag })}
>
<Tag className="w-3 h-3" />
<span className="font-mono">+{displayTag}</span>
</div>
);
}
if (
matchingIdentity &&
matchingIdentity.name &&
matchingIdentity.name !== matchingIdentity.email &&
matchingIdentity.name !== fromAddress
) {
return (
<div
className={cn(
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs',
'bg-secondary text-muted-foreground',
className
)}
title={t('identity_name', { name: matchingIdentity.name })}
>
<Mail className="w-3 h-3" />
<span className="truncate max-w-[100px]">{matchingIdentity.name}</span>
</div>
);
}
return null;
}
// Full view for email viewer - now shows compact badges only
return (
<div className={cn('inline-flex items-center gap-2', className)}>
{/* Sub-address tag badge */}
{displayTag && (
<div
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-md',
'bg-primary/10 text-primary border border-primary/20',
'text-xs font-semibold'
)}
title={t('sub_address_tag', { tag: displayTag })}
aria-label={t('sub_address_tag', { tag: displayTag })}
>
<Tag className="w-3 h-3" />
<span className="font-mono">{t('subaddress_tag', { tag: displayTag })}</span>
</div>
)}
{/* Identity badge (only if identity has a name and no sub-address tag) */}
{!displayTag &&
matchingIdentity &&
matchingIdentity.name &&
matchingIdentity.name !== matchingIdentity.email &&
matchingIdentity.name !== fromAddress && (
<div
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-md',
'bg-secondary text-muted-foreground border border-border',
'text-xs font-medium'
)}
title={t('identity_name', { name: matchingIdentity.name })}
aria-label={t('identity_name', { name: matchingIdentity.name })}
>
<Mail className="w-3 h-3" />
<span>{t('identity_short', { name: matchingIdentity.name })}</span>
</div>
)}
</div>
);
}
+7 -1
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types"; import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -7,7 +8,9 @@ import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react"; import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailDrag } from "@/hooks/use-email-drag"; import { useEmailDrag } from "@/hooks/use-email-drag";
import { EmailIdentityBadge } from "./email-identity-badge";
interface EmailListItemProps { interface EmailListItemProps {
email: Email; email: Email;
@@ -39,8 +42,10 @@ const getEmailColor = (keywords: Record<string, boolean> | undefined) => {
}; };
export function EmailListItem({ email, selected, onClick, onContextMenu }: EmailListItemProps) { export function EmailListItem({ email, selected, onClick, onContextMenu }: EmailListItemProps) {
const t = useTranslations('email_viewer');
const { selectedEmailIds, toggleEmailSelection, selectedMailbox } = useEmailStore(); const { selectedEmailIds, toggleEmailSelection, selectedMailbox } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
const { identities } = useAuthStore();
const isChecked = selectedEmailIds.has(email.id); const isChecked = selectedEmailIds.has(email.id);
const isUnread = !email.keywords?.$seen; const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged; const isStarred = email.keywords?.$flagged;
@@ -145,6 +150,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
Important Important
</span> </span>
)} )}
<EmailIdentityBadge email={email} identities={identities} compact={true} />
{email.hasAttachment && ( {email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" /> <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)} )}
@@ -167,7 +173,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
? "font-semibold text-foreground" ? "font-semibold text-foreground"
: "font-normal text-foreground/90" : "font-normal text-foreground/90"
)}> )}>
{email.subject || "(no subject)"} {email.subject || t('no_subject')}
</div> </div>
{/* Third Line: Preview (controlled by showPreview setting) */} {/* Third Line: Preview (controlled by showPreview setting) */}
+56 -15
View File
@@ -11,6 +11,7 @@ import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils"; import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
import { useContextMenu } from "@/hooks/use-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu";
import { useTranslations } from "next-intl";
interface EmailListProps { interface EmailListProps {
emails: Email[]; emails: Email[];
@@ -30,6 +31,8 @@ interface EmailListProps {
onArchive?: (email: Email) => void; onArchive?: (email: Email) => void;
onSetColorTag?: (emailId: string, color: string | null) => void; onSetColorTag?: (emailId: string, color: string | null) => void;
onMoveToMailbox?: (emailId: string, mailboxId: string) => void; onMoveToMailbox?: (emailId: string, mailboxId: string) => void;
onMarkAsSpam?: (email: Email) => void;
onUndoSpam?: (email: Email) => void;
} }
export function EmailList({ export function EmailList({
@@ -47,8 +50,11 @@ export function EmailList({
onDelete, onDelete,
onArchive, onArchive,
onSetColorTag, onSetColorTag,
onMarkAsSpam,
onUndoSpam,
onMoveToMailbox, onMoveToMailbox,
}: EmailListProps) { }: EmailListProps) {
const t = useTranslations('email_list');
const { client } = useAuthStore(); const { client } = useAuthStore();
const { const {
selectedEmailIds, selectedEmailIds,
@@ -57,6 +63,8 @@ export function EmailList({
batchMarkAsRead, batchMarkAsRead,
batchDelete, batchDelete,
batchMoveToMailbox, batchMoveToMailbox,
batchMarkAsSpam,
batchUndoSpam,
loadMoreEmails, loadMoreEmails,
hasMoreEmails, hasMoreEmails,
isLoadingMore, isLoadingMore,
@@ -188,7 +196,7 @@ export function EmailList({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleBatchMarkAsRead(true)} onClick={() => handleBatchMarkAsRead(true)}
title="Mark as read" title={t('batch_actions.mark_read')}
disabled={isProcessing} disabled={isProcessing}
className="hover:bg-accent transition-colors disabled:opacity-50" className="hover:bg-accent transition-colors disabled:opacity-50"
> >
@@ -202,7 +210,7 @@ export function EmailList({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleBatchMarkAsRead(false)} onClick={() => handleBatchMarkAsRead(false)}
title="Mark as unread" title={t('batch_actions.mark_unread')}
disabled={isProcessing} disabled={isProcessing}
className="hover:bg-accent transition-colors disabled:opacity-50" className="hover:bg-accent transition-colors disabled:opacity-50"
> >
@@ -216,7 +224,7 @@ export function EmailList({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleBatchDelete} onClick={handleBatchDelete}
title="Delete" title={t('batch_actions.delete')}
disabled={isProcessing} disabled={isProcessing}
className="text-red-600 dark:text-red-400 hover:bg-red-100/50 dark:hover:bg-red-950/30 transition-colors disabled:opacity-50" className="text-red-600 dark:text-red-400 hover:bg-red-100/50 dark:hover:bg-red-950/30 transition-colors disabled:opacity-50"
> >
@@ -231,7 +239,7 @@ export function EmailList({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={clearSelection} onClick={clearSelection}
title="Clear selection" title={t('batch_actions.clear_selection')}
disabled={isProcessing} disabled={isProcessing}
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50" className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
> >
@@ -261,13 +269,13 @@ export function EmailList({
)} )}
</button> </button>
<h2 className="text-sm font-medium text-foreground"> <h2 className="text-sm font-medium text-foreground">
{isLoading ? 'Loading...' : threadGroups.length > 0 {isLoading ? t('loading') : threadGroups.length > 0
? (totalEmails > threadGroups.length ? (totalEmails !== undefined && totalEmails > threadGroups.length
? `${threadGroups.length} of ${totalEmails} conversations` ? t('conversations_count', { count: threadGroups.length, total: totalEmails })
: hasMoreEmails : hasMoreEmails
? `${threadGroups.length}+ conversations` ? t('conversations_count_plus', { count: threadGroups.length })
: `${threadGroups.length} conversations`) : t('conversations_count_simple', { count: threadGroups.length }))
: 'No conversations'} : t('no_conversations')}
</h2> </h2>
</div> </div>
</div> </div>
@@ -279,7 +287,7 @@ export function EmailList({
<div className="absolute inset-0 bg-background/50 z-10 flex items-center justify-center animate-in fade-in duration-150"> <div className="absolute inset-0 bg-background/50 z-10 flex items-center justify-center animate-in fade-in duration-150">
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-background/90 px-4 py-2 rounded-full shadow-sm border border-border"> <div className="flex items-center gap-2 text-sm text-muted-foreground bg-background/90 px-4 py-2 rounded-full shadow-sm border border-border">
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
<span>Loading...</span> <span>{t('loading')}</span>
</div> </div>
</div> </div>
)} )}
@@ -290,8 +298,8 @@ export function EmailList({
) : emails.length === 0 && !isLoading ? ( ) : emails.length === 0 && !isLoading ? (
<div className="flex flex-col items-center justify-center h-full py-12"> <div className="flex flex-col items-center justify-center h-full py-12">
<Inbox className="w-16 h-16 mb-4 text-muted-foreground/50" /> <Inbox className="w-16 h-16 mb-4 text-muted-foreground/50" />
<p className="text-base font-medium text-foreground">No emails in this mailbox</p> <p className="text-base font-medium text-foreground">{t('no_emails')}</p>
<p className="text-sm mt-1 text-muted-foreground">New messages will appear here</p> <p className="text-sm mt-1 text-muted-foreground">{t('no_emails_description')}</p>
</div> </div>
) : ( ) : (
<div className={cn("transition-opacity duration-200", isLoading && "opacity-50")}> <div className={cn("transition-opacity duration-200", isLoading && "opacity-50")}>
@@ -315,12 +323,12 @@ export function EmailList({
{isLoadingMore && hasMoreEmails && ( {isLoadingMore && hasMoreEmails && (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
<span>Loading more emails...</span> <span>{t('loading_more')}</span>
</div> </div>
)} )}
{!hasMoreEmails && emails.length > 0 && ( {!hasMoreEmails && emails.length > 0 && (
<div className="text-sm text-muted-foreground border-t border-border pt-6"> <div className="text-sm text-muted-foreground border-t border-border pt-6">
No more emails to load {t('no_more_emails')}
</div> </div>
)} )}
</div> </div>
@@ -338,6 +346,7 @@ export function EmailList({
menuRef={menuRef} menuRef={menuRef}
mailboxes={mailboxes} mailboxes={mailboxes}
selectedMailbox={selectedMailbox} selectedMailbox={selectedMailbox}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)} isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
selectedCount={selectedEmailIds.size} selectedCount={selectedEmailIds.size}
// Single email actions // Single email actions
@@ -350,10 +359,42 @@ export function EmailList({
onArchive={() => onArchive?.(contextMenu.data!)} onArchive={() => onArchive?.(contextMenu.data!)}
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)} onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)} onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
// Batch actions // Batch actions
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)} onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
onBatchDelete={() => client && batchDelete(client)} onBatchDelete={() => client && batchDelete(client)}
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)} onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
onBatchMarkAsSpam={async () => {
if (client) {
const emailIds = Array.from(selectedEmailIds);
try {
await batchMarkAsSpam(client, emailIds);
const { toast } = await import('sonner');
toast.success(
t('../email_viewer.spam.toast_batch', { count: emailIds.length })
);
} catch {
const { toast } = await import('sonner');
toast.error(t('../email_viewer.spam.error'));
}
}
}}
onBatchUndoSpam={async () => {
if (client) {
const emailIds = Array.from(selectedEmailIds);
try {
await batchUndoSpam(client, emailIds);
const { toast } = await import('sonner');
toast.success(
t('../email_viewer.spam.toast_not_spam_batch', { count: emailIds.length })
);
} catch {
const { toast } = await import('sonner');
toast.error(t('../email_viewer.spam.error_not_spam'));
}
}
}}
/> />
)} )}
</div> </div>
+239 -106
View File
@@ -3,10 +3,11 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { Email } from "@/lib/jmap/types"; import { Email } from "@/lib/jmap/types";
import { hasRichFormatting, EMAIL_SANITIZE_CONFIG } from "@/lib/email-sanitization";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn } from "@/lib/utils"; import { formatFileSize, cn } from "@/lib/utils";
import { getSecurityStatus } from "@/lib/email-headers"; import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
import { import {
Reply, Reply,
ReplyAll, ReplyAll,
@@ -51,6 +52,11 @@ import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { useDeviceDetection } from "@/hooks/use-media-query"; import { useDeviceDetection } from "@/hooks/use-media-query";
import { useAuthStore } from "@/stores/auth-store";
import { useThemeStore } from "@/stores/theme-store";
import { transformInlineStyles } from "@/lib/color-transform";
import { EmailIdentityBadge } from "./email-identity-badge";
import { UnsubscribeBanner } from "./unsubscribe-banner";
interface EmailViewerProps { interface EmailViewerProps {
email: Email | null; email: Email | null;
@@ -65,9 +71,12 @@ interface EmailViewerProps {
onSetColorTag?: (emailId: string, color: string | null) => void; onSetColorTag?: (emailId: string, color: string | null) => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string) => void; onDownloadAttachment?: (blobId: string, name: string, type?: string) => void;
onQuickReply?: (body: string) => Promise<void>; onQuickReply?: (body: string) => Promise<void>;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
onBack?: () => void; onBack?: () => void;
currentUserEmail?: string; currentUserEmail?: string;
currentUserName?: string; currentUserName?: string;
currentMailboxRole?: string;
className?: string; className?: string;
} }
@@ -97,17 +106,6 @@ const getFileIcon = (name?: string, type?: string) => {
return File; return File;
}; };
// Color options for email tags
const colorOptions = [
{ name: "Red", value: "red", color: "bg-red-500" },
{ name: "Orange", value: "orange", color: "bg-orange-500" },
{ name: "Yellow", value: "yellow", color: "bg-yellow-500" },
{ name: "Green", value: "green", color: "bg-green-500" },
{ name: "Blue", value: "blue", color: "bg-blue-500" },
{ name: "Purple", value: "purple", color: "bg-purple-500" },
{ name: "Pink", value: "pink", color: "bg-pink-500" },
];
const getCurrentColor = (keywords: Record<string, boolean> | undefined) => { const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
if (!keywords) return null; if (!keywords) return null;
for (const key of Object.keys(keywords)) { for (const key of Object.keys(keywords)) {
@@ -118,6 +116,42 @@ const getCurrentColor = (keywords: Record<string, boolean> | undefined) => {
return null; return null;
}; };
// Helper function to format recipients with contextual display
const formatRecipients = (
recipients: Array<{ name?: string; email: string }> | undefined,
currentUserEmail: string | undefined,
t: (key: string, params?: Record<string, string | number>) => string
): string => {
if (!recipients || recipients.length === 0) return '';
// Check if the first recipient is the current user
const firstRecipient = recipients[0];
const isFirstRecipientMe = currentUserEmail &&
(firstRecipient.email.toLowerCase() === currentUserEmail.toLowerCase() ||
firstRecipient.email.toLowerCase().startsWith(currentUserEmail.toLowerCase().split('@')[0] + '+'));
// If only one recipient and it's the current user, show "me"
if (recipients.length === 1 && isFirstRecipientMe) {
return t('recipient_me');
}
// Format up to 2 recipients by name (or email if no name)
const displayRecipients = recipients.slice(0, 2).map((r, index) => {
if (index === 0 && isFirstRecipientMe) {
return t('recipient_me');
}
return r.name || r.email;
});
// If more than 2 recipients, add count
if (recipients.length > 2) {
const displayName = displayRecipients[0];
return t('recipient_and_others', { name: displayName, count: recipients.length - 1 });
}
return displayRecipients.join(', ');
};
export function EmailViewer({ export function EmailViewer({
email, email,
isLoading = false, isLoading = false,
@@ -131,9 +165,12 @@ export function EmailViewer({
onSetColorTag, onSetColorTag,
onDownloadAttachment, onDownloadAttachment,
onQuickReply, onQuickReply,
onMarkAsSpam,
onUndoSpam,
onBack, onBack,
currentUserEmail, currentUserEmail,
currentUserName, currentUserName,
currentMailboxRole,
className, className,
}: EmailViewerProps) { }: EmailViewerProps) {
const t = useTranslations('email_viewer'); const t = useTranslations('email_viewer');
@@ -143,9 +180,25 @@ export function EmailViewer({
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender); const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted); const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
// Detect if current mailbox is Junk folder
const isInJunkFolder = currentMailboxRole === 'junk';
// Color options for email tags (using translations)
const colorOptions = [
{ name: t("color_tag.red"), value: "red", color: "bg-red-500" },
{ name: t("color_tag.orange"), value: "orange", color: "bg-orange-500" },
{ name: t("color_tag.yellow"), value: "yellow", color: "bg-yellow-500" },
{ name: t("color_tag.green"), value: "green", color: "bg-green-500" },
{ name: t("color_tag.blue"), value: "blue", color: "bg-blue-500" },
{ name: t("color_tag.purple"), value: "purple", color: "bg-purple-500" },
{ name: t("color_tag.pink"), value: "pink", color: "bg-pink-500" },
];
// Tablet list visibility // Tablet list visibility
const { isTablet } = useDeviceDetection(); const { isTablet } = useDeviceDetection();
const { tabletListVisible } = useUIStore(); const { tabletListVisible } = useUIStore();
const { identities } = useAuthStore();
const theme = useThemeStore((state) => state.theme);
const [showFullHeaders, setShowFullHeaders] = useState(false); const [showFullHeaders, setShowFullHeaders] = useState(false);
const [allowExternalContent, setAllowExternalContent] = useState(false); const [allowExternalContent, setAllowExternalContent] = useState(false);
const [hasBlockedContent, setHasBlockedContent] = useState(false); const [hasBlockedContent, setHasBlockedContent] = useState(false);
@@ -154,6 +207,13 @@ export function EmailViewer({
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false); const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
const [showSourceModal, setShowSourceModal] = useState(false); const [showSourceModal, setShowSourceModal] = useState(false);
const currentColor = getCurrentColor(email?.keywords); const currentColor = getCurrentColor(email?.keywords);
const [dismissedUnsubBanners, setDismissedUnsubBanners] = useState<Set<string>>(
() => {
if (typeof window === 'undefined') return new Set();
const saved = localStorage.getItem('dismissed-unsub-banners');
return saved ? new Set(JSON.parse(saved)) : new Set();
}
);
useEffect(() => { useEffect(() => {
// Mark as read when email is viewed // Mark as read when email is viewed
@@ -339,16 +399,8 @@ export function EmailViewer({
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) { if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
htmlContent = email.bodyValues[email.htmlBody[0].partId].value; htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
// Check if HTML is just a minimal wrapper around plain text // Use safe parsing instead of innerHTML to detect rich formatting
// by checking if it lacks common HTML formatting elements useHtmlVersion = hasRichFormatting(htmlContent);
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
const hasRichFormatting = tempDiv.querySelector('table, img, style, b, strong, i, em, u, font, div[style], span[style], p[style], h1, h2, h3, h4, h5, h6, ul, ol, blockquote');
const hasMultipleParagraphs = tempDiv.querySelectorAll('p').length > 2;
const hasBrTags = tempDiv.querySelectorAll('br').length > 0;
// Use HTML if it has rich formatting, multiple paragraphs, or explicit line breaks
useHtmlVersion = !!(hasRichFormatting || hasMultipleParagraphs || hasBrTags);
} }
// If we should use HTML version and it exists // If we should use HTML version and it exists
@@ -356,14 +408,8 @@ export function EmailViewer({
// Create a custom DOMPurify hook to handle external content // Create a custom DOMPurify hook to handle external content
let blockedExternalContent = false; let blockedExternalContent = false;
const sanitizeConfig = { // Use shared sanitization config as base (more secure)
ADD_TAGS: ['style'], const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
ADD_ATTR: ['target', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
ALLOW_DATA_ATTR: false,
FORCE_BODY: true,
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'],
};
// Check if sender is trusted // Check if sender is trusted
const senderEmail = email.from?.[0]?.email?.toLowerCase(); const senderEmail = email.from?.[0]?.email?.toLowerCase();
@@ -406,6 +452,15 @@ export function EmailViewer({
blockedExternalContent = true; blockedExternalContent = true;
} }
} }
// Transform inline color styles for dark mode readability
if (theme === 'dark') {
const originalStyles = htmlNode.style.cssText;
const transformedStyles = transformInlineStyles(originalStyles, 'dark');
if (transformedStyles !== originalStyles) {
htmlNode.style.cssText = transformedStyles;
}
}
} }
}); });
} }
@@ -460,16 +515,26 @@ export function EmailViewer({
.replace(/\n/g, '<br>'); .replace(/\n/g, '<br>');
return { return {
html: `<div style="color: #666; font-style: italic;">${previewHtml}</div>`, html: `<div style="color: var(--color-muted-foreground); font-style: italic;">${previewHtml}</div>`,
isHtml: false isHtml: false
}; };
} }
return { return {
html: '<p style="color: #999;">No content available</p>', html: '<p style="color: var(--color-muted-foreground);">No content available</p>',
isHtml: false isHtml: false
}; };
}, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted]); }, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, theme]);
// Detect List-Unsubscribe header for newsletter banners
const listHeaders = useMemo(() => {
if (!email?.headers) return null;
return extractListHeaders(email.headers);
}, [email?.headers]);
const shouldShowUnsubBanner =
listHeaders?.listUnsubscribe?.preferred &&
!dismissedUnsubBanners.has(email?.messageId || '');
// Show loading skeleton while email is being fetched // Show loading skeleton while email is being fetched
if (isLoading && !email) { if (isLoading && !email) {
@@ -574,7 +639,7 @@ export function EmailViewer({
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h1 className="text-lg lg:text-2xl font-bold text-foreground tracking-tight truncate pr-2"> <h1 className="text-lg lg:text-2xl font-bold text-foreground tracking-tight truncate pr-2">
{email.subject || "(no subject)"} {email.subject || t('no_subject')}
</h1> </h1>
<div className="flex items-center gap-2 lg:gap-3 mt-1.5 lg:mt-2 text-xs lg:text-sm text-muted-foreground flex-wrap lg:flex-nowrap"> <div className="flex items-center gap-2 lg:gap-3 mt-1.5 lg:mt-2 text-xs lg:text-sm text-muted-foreground flex-wrap lg:flex-nowrap">
<span className="flex items-center gap-1 lg:gap-1.5 whitespace-nowrap"> <span className="flex items-center gap-1 lg:gap-1.5 whitespace-nowrap">
@@ -616,7 +681,7 @@ export function EmailViewer({
onClick={onReply} onClick={onReply}
size="sm" size="sm"
className="mr-1 h-8 lg:h-9" className="mr-1 h-8 lg:h-9"
title="Reply" title={t('tooltips.reply')}
> >
<Reply className="w-4 h-4" /> <Reply className="w-4 h-4" />
<span className="ml-1.5 hidden lg:inline">Reply</span> <span className="ml-1.5 hidden lg:inline">Reply</span>
@@ -657,16 +722,39 @@ export function EmailViewer({
size="icon" size="icon"
onClick={onArchive} onClick={onArchive}
className="h-8 w-8 hover:bg-muted hidden lg:flex" className="h-8 w-8 hover:bg-muted hidden lg:flex"
title="Archive" title={t('tooltips.archive')}
> >
<Archive className="w-4 h-4 text-muted-foreground" /> <Archive className="w-4 h-4 text-muted-foreground" />
</Button> </Button>
{/* Spam/Not Spam Button - Desktop only, contextual based on folder */}
{(onMarkAsSpam || onUndoSpam) && (
<Button
variant="ghost"
size="icon"
onClick={isInJunkFolder ? onUndoSpam : onMarkAsSpam}
className={cn(
"hidden h-8 w-8 lg:flex",
isInJunkFolder
? "hover:bg-green-50 dark:hover:bg-green-950/30"
: "hover:bg-red-50 dark:hover:bg-red-950/30"
)}
title={isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
>
{isInJunkFolder ? (
<ShieldCheck className="h-4 w-4 text-green-600 dark:text-green-400" />
) : (
<ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-400" />
)}
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onDelete} onClick={onDelete}
className="h-8 w-8 hover:bg-muted" className="h-8 w-8 hover:bg-muted"
title="Delete" title={t('tooltips.delete')}
> >
<Trash2 className="w-4 h-4 text-muted-foreground" /> <Trash2 className="w-4 h-4 text-muted-foreground" />
</Button> </Button>
@@ -768,6 +856,30 @@ export function EmailViewer({
<Printer className="w-4 h-4" /> <Printer className="w-4 h-4" />
{t('print')} {t('print')}
</button> </button>
{/* Separator */}
<div className="h-px bg-border my-1" />
{/* Spam action - contextual */}
{(onMarkAsSpam || onUndoSpam) && (
<button
onClick={isInJunkFolder ? onUndoSpam : onMarkAsSpam}
className={cn(
"w-full px-3 py-2 text-sm text-left hover:bg-muted flex items-center gap-2",
isInJunkFolder ? "text-green-700 dark:text-green-400" : "text-red-700 dark:text-red-400"
)}
>
{isInJunkFolder ? (
<>
<ShieldCheck className="w-4 h-4" />
{t('spam.not_spam_title')}
</>
) : (
<>
<ShieldAlert className="w-4 h-4" />
{t('spam.button_title')}
</>
)}
</button>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -786,40 +898,39 @@ export function EmailViewer({
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Sender line with compact badges */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-foreground"> <span className="font-semibold text-foreground">
{sender?.name || sender?.email || t('unknown_sender')} {sender?.name || sender?.email || t('unknown_sender')}
</span> </span>
{sender?.email && sender?.name && ( <EmailIdentityBadge email={email} identities={identities} />
<span className="text-sm text-muted-foreground">
&lt;{sender.email}&gt;
</span>
)}
</div> </div>
{/* Recipient section - separate line */}
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
{email.to && email.to.length > 0 && ( {email.to && email.to.length > 0 && (
<div className="flex flex-wrap items-center gap-1 text-sm"> <div className="flex flex-wrap items-center gap-1 text-sm">
<span className="text-muted-foreground">To:</span> <span className="text-muted-foreground">{t('recipient_to_prefix')}</span>
<span className="text-foreground"> <span className="text-foreground">
{email.to.slice(0, 2).map(r => r.name || r.email).join(", ")} {formatRecipients(email.to, currentUserEmail, t)}
{email.to.length > 2 && (
<button
onClick={() => setShowFullHeaders(!showFullHeaders)}
className="ml-1 text-blue-600 dark:text-blue-400 hover:underline"
>
{t('more_count', { count: email.to.length - 2 })}
</button>
)}
</span> </span>
{email.to.length > 2 && (
<button
onClick={() => setShowFullHeaders(!showFullHeaders)}
className="ml-1 text-blue-600 dark:text-blue-400 hover:underline"
>
{t('more_count', { count: email.to.length - 2 })}
</button>
)}
</div> </div>
)} )}
{(email.cc && email.cc.length > 0) && ( {email.cc && email.cc.length > 0 && (
<div className="flex flex-wrap items-center gap-1 text-sm"> <div className="flex flex-wrap items-center gap-1 text-sm">
<span className="text-muted-foreground">CC:</span> <span className="text-muted-foreground">CC:</span>
<span className="text-foreground"> <span className="text-foreground">
{email.cc.map(r => r.name || r.email).join(", ")} {email.cc.slice(0, 2).map(r => r.name || r.email).join(", ")}
{email.cc.length > 2 && ` +${email.cc.length - 2}`}
</span> </span>
</div> </div>
)} )}
@@ -1139,72 +1250,94 @@ export function EmailViewer({
className="shadow-sm w-10 h-10" className="shadow-sm w-10 h-10"
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Mobile 2-line layout */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-foreground"> <span className="text-sm font-semibold text-foreground">
{sender?.name || sender?.email || t('unknown_sender')} {sender?.name || sender?.email || t('unknown_sender')}
</span> </span>
<EmailIdentityBadge email={email} identities={identities} />
</div>
<div className="mt-1 flex items-center gap-1 text-sm text-muted-foreground flex-wrap">
{sender?.email && sender?.name && ( {sender?.email && sender?.name && (
<span className="text-sm text-muted-foreground"> <>
&lt;{sender.email}&gt; <span className="truncate">{sender.email}</span>
</span> <span>·</span>
</>
)} )}
</div>
<div className="mt-1 space-y-0.5">
{email.to && email.to.length > 0 && ( {email.to && email.to.length > 0 && (
<div className="flex flex-wrap items-center gap-1 text-sm"> <>
<span className="text-muted-foreground">To:</span> <span> {t('recipient_to_prefix')}</span>
<span className="text-foreground truncate"> <span className="text-foreground">
{email.to.slice(0, 2).map(r => r.name || r.email).join(", ")} {formatRecipients(email.to, currentUserEmail, t)}
{email.to.length > 2 && ` +${email.to.length - 2}`}
</span> </span>
</div> </>
)}
{email.cc && email.cc.length > 0 && (
<div className="flex flex-wrap items-center gap-1 text-sm">
<span className="text-muted-foreground">CC:</span>
<span className="text-foreground truncate">
{email.cc.slice(0, 2).map(r => r.name || r.email).join(", ")}
{email.cc.length > 2 && ` +${email.cc.length - 2}`}
</span>
</div>
)} )}
</div> </div>
{/* CC line (mobile - only if present) */}
{email.cc && email.cc.length > 0 && (
<div className="mt-1 flex items-center gap-1 text-sm">
<span className="text-muted-foreground">CC:</span>
<span className="text-foreground truncate">
{email.cc.slice(0, 2).map(r => r.name || r.email).join(", ")}
{email.cc.length > 2 && ` +${email.cc.length - 2}`}
</span>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
{/* External Content Banner - show in 'ask' or 'block' mode */} {/* Unified Notification Banner - External Content + Unsubscribe */}
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && ( {((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
<div className="border-b border-border"> (shouldShowUnsubBanner && listHeaders?.listUnsubscribe)) && (
<div className="max-w-4xl mx-auto px-6 py-2 flex items-center justify-center gap-4"> <div className="border-b border-border bg-muted/30 isolate">
{/* Load images button - only in 'ask' mode */} <div className="max-w-4xl mx-auto px-6 py-1.5">
{externalContentPolicy === 'ask' && ( <div className="flex flex-col md:flex-row md:items-center md:justify-center gap-3 isolate">
<button {/* External Content Controls */}
onClick={() => setAllowExternalContent(true)} {hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors" <div className="flex items-center gap-3 flex-wrap">
> {/* Load images button - only in 'ask' mode */}
<Image className="w-3.5 h-3.5" /> {externalContentPolicy === 'ask' && (
{t('load_external_content')} <button
</button> onClick={() => setAllowExternalContent(true)}
)} className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
{/* Trust sender button - in both 'ask' and 'block' modes */} >
{email.from?.[0]?.email && ( <Image className="w-3.5 h-3.5" />
<> {t('load_external_content')}
{externalContentPolicy === 'ask' && <span className="text-muted-foreground/50">|</span>} </button>
<button )}
onClick={() => { {/* Trust sender button - in both 'ask' and 'block' modes */}
const senderEmail = email.from?.[0]?.email; {email.from?.[0]?.email && (
if (senderEmail) { <button
addTrustedSender(senderEmail); onClick={() => {
setAllowExternalContent(true); const senderEmail = email.from?.[0]?.email;
} if (senderEmail) {
addTrustedSender(senderEmail);
setAllowExternalContent(true);
}
}}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
{t('trust_sender')}
</button>
)}
</div>
)}
{/* Unsubscribe Controls */}
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
listUnsubscribe={listHeaders.listUnsubscribe}
senderEmail={email?.from?.[0]?.email || ''}
onDismiss={() => {
const messageId = email?.messageId || '';
const newSet = new Set(dismissedUnsubBanners).add(messageId);
setDismissedUnsubBanners(newSet);
localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet]));
}} }}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors" />
> )}
{t('trust_sender')} </div>
</button>
</>
)}
</div> </div>
</div> </div>
)} )}
+5 -13
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types"; import { Email, ThreadGroup } from "@/lib/jmap/types";
import { hasRichFormatting, EMAIL_SANITIZE_CONFIG } from "@/lib/email-sanitization";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { formatDate, formatFileSize, cn } from "@/lib/utils"; import { formatDate, formatFileSize, cn } from "@/lib/utils";
@@ -260,24 +261,15 @@ function EmailCard({
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) { if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
htmlContent = email.bodyValues[email.htmlBody[0].partId].value; htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
const tempDiv = document.createElement('div'); // Use safe parsing instead of innerHTML to detect rich formatting
tempDiv.innerHTML = htmlContent; useHtmlVersion = hasRichFormatting(htmlContent);
const hasRichFormatting = tempDiv.querySelector('table, img, style, b, strong, i, em, u, font, div[style], span[style], p[style], h1, h2, h3, h4, h5, h6, ul, ol, blockquote');
const hasMultipleParagraphs = tempDiv.querySelectorAll('p').length > 2;
const hasBrTags = tempDiv.querySelectorAll('br').length > 0;
useHtmlVersion = !!(hasRichFormatting || hasMultipleParagraphs || hasBrTags);
} }
if (useHtmlVersion && htmlContent) { if (useHtmlVersion && htmlContent) {
let blockedExternalContent = false; let blockedExternalContent = false;
const sanitizeConfig = { // Use shared sanitization config as base (more secure)
ADD_TAGS: ['style'], const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
ADD_ATTR: ['target', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'meta', 'link', 'base'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange', 'onsubmit'],
};
if (!allowExternal) { if (!allowExternal) {
DOMPurify.addHook('afterSanitizeAttributes', (node) => { DOMPurify.addHook('afterSanitizeAttributes', (node) => {
+3 -1
View File
@@ -9,6 +9,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { getThreadColorTag } from "@/lib/thread-utils"; import { getThreadColorTag } from "@/lib/thread-utils";
import { ThreadEmailItem } from "./thread-email-item"; import { ThreadEmailItem } from "./thread-email-item";
import { useTranslations } from "next-intl";
interface ThreadListItemProps { interface ThreadListItemProps {
thread: ThreadGroup; thread: ThreadGroup;
@@ -44,6 +45,7 @@ export function ThreadListItem({
onContextMenu, onContextMenu,
onOpenConversation, onOpenConversation,
}: ThreadListItemProps) { }: ThreadListItemProps) {
const t = useTranslations('threads');
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
const isMobile = useUIStore((state) => state.isMobile); const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread; const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
@@ -235,7 +237,7 @@ export function ThreadListItem({
{isLoading ? ( {isLoading ? (
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground"> <div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin mr-2" /> <Loader2 className="w-4 h-4 animate-spin mr-2" />
Loading conversation... {t('loading')}
</div> </div>
) : ( ) : (
emailsToShow.map((email, index) => ( emailsToShow.map((email, index) => (
+135
View File
@@ -0,0 +1,135 @@
'use client';
import { useState } from 'react';
import { Loader2, CheckCircle, AlertCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { isValidUnsubscribeUrl } from '@/lib/validation';
interface UnsubscribeBannerProps {
listUnsubscribe: {
http?: string;
mailto?: string;
preferred?: 'http' | 'mailto';
};
senderEmail: string;
onDismiss: () => void;
}
export function UnsubscribeBanner({
listUnsubscribe,
senderEmail: _senderEmail,
onDismiss
}: UnsubscribeBannerProps) {
const t = useTranslations();
const [showConfirm, setShowConfirm] = useState(false);
const [processing, setProcessing] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState(false);
const unsubMethod = listUnsubscribe.preferred;
const unsubUrl = unsubMethod === 'http'
? listUnsubscribe.http
: listUnsubscribe.mailto;
if (!unsubUrl || !unsubMethod) {
return null;
}
const handleUnsubscribe = async () => {
if (!isValidUnsubscribeUrl(unsubUrl)) {
setError(true);
setProcessing(false);
return;
}
setProcessing(true);
try {
if (unsubMethod === 'http') {
window.open(unsubUrl, '_blank', 'noopener,noreferrer');
setSuccess(true);
setProcessing(false);
setTimeout(onDismiss, 3000);
} else {
const link = document.createElement('a');
link.href = unsubUrl;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setSuccess(true);
setProcessing(false);
setTimeout(onDismiss, 3000);
}
} catch (err) {
console.error('Unsubscribe error:', err);
setError(true);
setProcessing(false);
}
};
if (success) {
return (
<div className="flex items-center gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-400" />
<span className="text-sm text-muted-foreground">
{t(unsubMethod === 'http'
? 'email_viewer.unsubscribe_banner.success_http'
: 'email_viewer.unsubscribe_banner.success_mailto'
)}
</span>
</div>
);
}
if (error) {
return (
<div className="flex items-center gap-2 flex-wrap">
<AlertCircle className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{t('email_viewer.unsubscribe_banner.error')}
</span>
<button
onClick={onDismiss}
className="text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
{t('email_viewer.unsubscribe_banner.dismiss')}
</button>
</div>
);
}
return (
<div className="flex items-center gap-2 flex-wrap">
{showConfirm ? (
<>
<span className="text-sm text-muted-foreground">
{t('email_viewer.unsubscribe_banner.confirm_title')}
</span>
<button
onClick={handleUnsubscribe}
disabled={processing}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] md:min-h-0"
>
{processing && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{t('email_viewer.unsubscribe_banner.confirm_button')}
</button>
<button
onClick={() => setShowConfirm(false)}
className="text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
{t('email_viewer.unsubscribe_banner.cancel')}
</button>
</>
) : (
<button
onClick={() => setShowConfirm(true)}
className="text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
{t('email_viewer.unsubscribe_banner.button')}
</button>
)}
</div>
);
}
+306
View File
@@ -0,0 +1,306 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
import { getEmailValidationError, validateEmailList } from '@/lib/validation';
interface IdentityFormData {
name: string;
email: string;
replyTo?: EmailAddress[];
bcc?: EmailAddress[];
textSignature?: string;
htmlSignature?: string;
}
interface IdentityFormProps {
identity?: Identity;
onSave: (data: IdentityFormData) => Promise<void>;
onCancel: () => void;
}
export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps) {
const t = useTranslations('identities.form');
const tValidation = useTranslations('identities.validation_errors');
const tDisplay = useTranslations('identities.display');
const isEditing = !!identity;
const [formData, setFormData] = useState<IdentityFormData>({
name: identity?.name || '',
email: identity?.email || '',
replyTo: identity?.replyTo,
bcc: identity?.bcc,
textSignature: identity?.textSignature || '',
htmlSignature: identity?.htmlSignature || '',
});
const [replyToInput, setReplyToInput] = useState(
identity?.replyTo?.map(a => a.email).join(', ') || ''
);
const [bccInput, setBccInput] = useState(
identity?.bcc?.map(a => a.email).join(', ') || ''
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const parseEmailList = (input: string): EmailAddress[] | undefined => {
if (!input.trim()) return undefined;
const emails = input.split(',').map(e => e.trim()).filter(Boolean);
return emails.map(email => ({ email }));
};
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = t('name_required');
}
// Use secure email validation
const emailError = getEmailValidationError(formData.email);
if (emailError) {
newErrors.email = emailError;
}
// Validate reply-to email list
if (replyToInput.trim()) {
const validation = validateEmailList(replyToInput);
if (!validation.valid) {
newErrors.replyTo = tValidation('invalid_emails', { emails: validation.invalidEmails.join(', ') });
}
}
// Validate bcc email list
if (bccInput.trim()) {
const validation = validateEmailList(bccInput);
if (!validation.valid) {
newErrors.bcc = tValidation('invalid_emails', { emails: validation.invalidEmails.join(', ') });
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setIsSubmitting(true);
try {
// Sanitize HTML signature before sending to server
const sanitizedData: IdentityFormData = {
...formData,
replyTo: parseEmailList(replyToInput),
bcc: parseEmailList(bccInput),
htmlSignature: formData.htmlSignature
? sanitizeSignatureHtml(formData.htmlSignature)
: undefined,
};
await onSave(sanitizedData);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name */}
<div>
<label htmlFor="identity-name" className="block text-sm font-medium mb-1">
{t('name_label')} <span className="text-destructive">*</span>
</label>
<Input
id="identity-name"
type="text"
maxLength={256}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={t('name_placeholder')}
disabled={isSubmitting}
className={errors.name ? 'border-destructive' : ''}
aria-describedby={errors.name ? 'name-error' : undefined}
aria-invalid={!!errors.name}
/>
{errors.name && (
<p
id="name-error"
className="text-sm text-destructive mt-1"
role="alert"
aria-live="polite"
aria-atomic="true"
>
{errors.name}
</p>
)}
</div>
{/* Email */}
<div>
<label htmlFor="identity-email" className="block text-sm font-medium mb-1">
{t('email_label')} <span className="text-destructive">*</span>
</label>
<Input
id="identity-email"
type="email"
maxLength={254}
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder={t('email_placeholder')}
disabled={isSubmitting || isEditing}
className={errors.email ? 'border-destructive' : ''}
aria-describedby={errors.email ? 'email-error' : undefined}
aria-invalid={!!errors.email}
/>
{isEditing && (
<p className="text-sm text-muted-foreground mt-1">
{t('email_immutable')}
</p>
)}
{errors.email && (
<p
id="email-error"
className="text-sm text-destructive mt-1"
role="alert"
aria-live="polite"
aria-atomic="true"
>
{errors.email}
</p>
)}
</div>
{/* Reply-To */}
<div>
<label htmlFor="identity-reply-to" className="block text-sm font-medium mb-1">
{t('reply_to_label')}
</label>
<Input
id="identity-reply-to"
type="text"
maxLength={512}
value={replyToInput}
onChange={(e) => setReplyToInput(e.target.value)}
placeholder={t('reply_to_placeholder')}
disabled={isSubmitting}
className={errors.replyTo ? 'border-destructive' : ''}
aria-describedby={errors.replyTo ? 'reply-to-error' : undefined}
aria-invalid={!!errors.replyTo}
/>
{errors.replyTo && (
<p
id="reply-to-error"
className="text-sm text-destructive mt-1"
role="alert"
aria-live="polite"
aria-atomic="true"
>
{errors.replyTo}
</p>
)}
</div>
{/* BCC */}
<div>
<label htmlFor="identity-bcc" className="block text-sm font-medium mb-1">
{t('bcc_label')}
</label>
<Input
id="identity-bcc"
type="text"
maxLength={512}
value={bccInput}
onChange={(e) => setBccInput(e.target.value)}
placeholder={t('bcc_placeholder')}
disabled={isSubmitting}
className={errors.bcc ? 'border-destructive' : ''}
aria-describedby={errors.bcc ? 'bcc-error' : undefined}
aria-invalid={!!errors.bcc}
/>
{errors.bcc && (
<p
id="bcc-error"
className="text-sm text-destructive mt-1"
role="alert"
aria-live="polite"
aria-atomic="true"
>
{errors.bcc}
</p>
)}
</div>
{/* Text Signature */}
<div>
<label htmlFor="identity-text-sig" className="block text-sm font-medium mb-1">
{t('text_signature_label')}
</label>
<textarea
id="identity-text-sig"
maxLength={2000}
value={formData.textSignature}
onChange={(e) => setFormData({ ...formData, textSignature: e.target.value })}
rows={3}
disabled={isSubmitting}
aria-label={t('text_signature_label')}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
{/* HTML Signature */}
<div>
<label htmlFor="identity-html-sig" className="block text-sm font-medium mb-1">
{t('html_signature_label')}
</label>
<textarea
id="identity-html-sig"
maxLength={5000}
value={formData.htmlSignature}
onChange={(e) => setFormData({ ...formData, htmlSignature: e.target.value })}
rows={5}
disabled={isSubmitting}
aria-label={t('html_signature_label')}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground font-mono transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
{formData.htmlSignature && (
<div className="mt-2 p-2 border rounded bg-muted">
<div className="text-xs text-muted-foreground mb-1">{tDisplay('preview')}</div>
<div
dangerouslySetInnerHTML={{
__html: sanitizeSignatureHtml(formData.htmlSignature)
}}
/>
</div>
)}
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
{t('cancel')}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? isEditing
? t('updating')
: t('creating')
: t('save')}
</Button>
</div>
</form>
);
}
@@ -0,0 +1,302 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { X, Mail, Pencil, Trash2, Plus, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { IdentityForm } from './identity-form';
import { useIdentityStore } from '@/stores/identity-store';
import { useAuthStore } from '@/stores/auth-store';
import type { Identity, EmailAddress } from '@/lib/jmap/types';
import { toast } from '@/stores/toast-store';
import { useFocusTrap } from '@/hooks/use-focus-trap';
interface IdentityFormData {
name: string;
email: string;
replyTo?: EmailAddress[];
bcc?: EmailAddress[];
textSignature?: string;
htmlSignature?: string;
}
interface IdentityManagerModalProps {
isOpen: boolean;
onClose: () => void;
}
export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalProps) {
const t = useTranslations('identities');
const tNotif = useTranslations('notifications');
const client = useAuthStore((state) => state.client);
const { identities, addIdentity, updateIdentityLocal, removeIdentity } = useIdentityStore();
const [editingId, setEditingId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
// Focus trap with Escape handling
const modalRef = useFocusTrap({
isActive: isOpen,
onEscape: () => {
if (isCreating || editingId) {
setIsCreating(false);
setEditingId(null);
} else {
onClose();
}
},
restoreFocus: true,
});
// Close on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen, onClose, modalRef]);
const handleCreate = useCallback(async (data: IdentityFormData) => {
if (!client) return;
try {
const newIdentity = await client.createIdentity(
data.name,
data.email,
data.replyTo,
data.bcc,
data.textSignature,
data.htmlSignature
);
addIdentity(newIdentity);
setIsCreating(false);
toast.success(tNotif('identity_created'));
} catch (error) {
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
toast.error(tNotif('identity_create_failed', { error: message }));
throw error;
}
}, [client, addIdentity, t, tNotif]);
const handleUpdate = useCallback(async (identity: Identity, data: IdentityFormData) => {
if (!client) return;
try {
await client.updateIdentity(identity.id, {
name: data.name,
replyTo: data.replyTo,
bcc: data.bcc,
textSignature: data.textSignature,
htmlSignature: data.htmlSignature,
});
updateIdentityLocal(identity.id, data);
setEditingId(null);
toast.success(tNotif('identity_updated'));
} catch (error) {
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
toast.error(tNotif('identity_update_failed', { error: message }));
throw error;
}
}, [client, updateIdentityLocal, t, tNotif]);
const handleDelete = useCallback(async (identity: Identity) => {
if (!client) return;
if (!identity.mayDelete) {
toast.error(t('cannot_delete'));
return;
}
if (!window.confirm(t('delete_confirm'))) {
return;
}
setDeletingId(identity.id);
try {
await client.deleteIdentity(identity.id);
removeIdentity(identity.id);
toast.success(tNotif('identity_deleted'));
} catch (error) {
const message = error instanceof Error ? error.message : t('validation_errors.unknown_error');
toast.error(tNotif('identity_delete_failed', { error: message }));
} finally {
setDeletingId(null);
}
}, [client, removeIdentity, t, tNotif]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="identity-modal-title"
className={cn(
'bg-background border border-border rounded-lg shadow-xl',
'w-full max-w-3xl max-h-[90vh] overflow-hidden',
'animate-in zoom-in-95 duration-200'
)}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
<Mail className="w-5 h-5 text-muted-foreground" />
<h2 id="identity-modal-title" className="text-lg font-semibold text-foreground">
{t('modal_title')}
</h2>
</div>
<button
onClick={onClose}
className="p-2 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
{/* Create New Form */}
{isCreating && (
<div className="mb-6 p-4 border border-border rounded-lg bg-muted/30">
<h3 className="text-sm font-semibold mb-4">{t('create_new')}</h3>
<IdentityForm
onSave={handleCreate}
onCancel={() => setIsCreating(false)}
/>
</div>
)}
{/* Create Button */}
{!isCreating && !editingId && (
<Button
onClick={() => setIsCreating(true)}
className="mb-6 w-full sm:w-auto"
>
<Plus className="w-4 h-4 mr-2" />
{t('create_new')}
</Button>
)}
{/* Identities List */}
<div className="space-y-4">
{identities.map((identity) => (
<div
key={identity.id}
className="border border-border rounded-lg overflow-hidden"
>
{editingId === identity.id ? (
<div className="p-4 bg-muted/30">
<h3 className="text-sm font-semibold mb-4">
{t('edit_identity')}
</h3>
<IdentityForm
identity={identity}
onSave={(data) => handleUpdate(identity, data)}
onCancel={() => setEditingId(null)}
/>
</div>
) : (
<div className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-foreground truncate">
{identity.name}
</h3>
{identities[0]?.id === identity.id && (
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
{t('primary_identity')}
</span>
)}
</div>
<p className="text-sm text-muted-foreground truncate">
{identity.email}
</p>
{/* Additional Info */}
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
{identity.replyTo && identity.replyTo.length > 0 && (
<p>
{t('display.reply_to')} {identity.replyTo.map((a) => a.email).join(', ')}
</p>
)}
{identity.bcc && identity.bcc.length > 0 && (
<p>
{t('display.bcc')} {identity.bcc.map((a) => a.email).join(', ')}
</p>
)}
{identity.textSignature && (
<p className="line-clamp-2">
{t('display.signature')} {identity.textSignature}
</p>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setEditingId(identity.id)}
disabled={!!editingId || isCreating}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(identity)}
disabled={
!identity.mayDelete ||
!!editingId ||
isCreating ||
deletingId === identity.id
}
className={!identity.mayDelete ? 'opacity-30' : ''}
>
{!identity.mayDelete ? (
<AlertTriangle className="w-4 h-4 text-yellow-500" />
) : (
<Trash2 className="w-4 h-4 text-destructive" />
)}
</Button>
</div>
</div>
{!identity.mayDelete && (
<p className="text-xs text-yellow-600 dark:text-yellow-500 mt-2 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
{t('cannot_delete')}
</p>
)}
</div>
)}
</div>
))}
{identities.length === 0 && !isCreating && (
<div className="text-center py-12 text-muted-foreground">
<Mail className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">{t('no_identities')}</p>
</div>
)}
</div>
</div>
</div>
</div>
);
}
+245
View File
@@ -0,0 +1,245 @@
'use client';
import { useState, useRef, useEffect, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Plus, Tag, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useIdentityStore } from '@/stores/identity-store';
import {
generateSubAddress,
extractDomain,
suggestTagsForDomain,
getTagValidationError,
MAX_TAG_LENGTH,
} from '@/lib/sub-addressing';
interface SubAddressHelperProps {
baseEmail: string;
recipientEmails: string[];
onSelectTag: (tag: string) => void;
disabled?: boolean;
}
export function SubAddressHelper({
baseEmail,
recipientEmails,
onSelectTag,
disabled = false,
}: SubAddressHelperProps) {
const t = useTranslations('identities.sub_address');
const [isOpen, setIsOpen] = useState(false);
const [tag, setTag] = useState('');
const [error, setError] = useState<string | null>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const { subAddress, addRecentTag, addTagSuggestion } = useIdentityStore();
// Get suggestions based on recipient (memoized for performance)
const suggestions = useMemo(() => {
return recipientEmails
.map(extractDomain)
.filter(Boolean)
.flatMap((domain) => suggestTagsForDomain(domain!))
.filter((tag, index, self) => self.indexOf(tag) === index)
.slice(0, 5);
}, [recipientEmails]);
// Generate preview
const preview = tag ? generateSubAddress(baseEmail, tag) : baseEmail;
// Close popover when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen]);
const handleTagChange = (value: string) => {
setTag(value);
const errorCode = getTagValidationError(value);
// Translate error code to localized message
let errorMessage: string | null = null;
if (errorCode === 'EMPTY') {
errorMessage = t('validation.empty');
} else if (errorCode === 'TOO_LONG') {
errorMessage = t('validation.too_long', { max: MAX_TAG_LENGTH });
} else if (errorCode === 'INVALID_CHARS') {
errorMessage = t('validation.invalid_chars');
}
setError(errorMessage);
};
const handleSelectTag = (selectedTag: string) => {
const errorCode = getTagValidationError(selectedTag);
if (errorCode) {
// Translate error code to localized message
let errorMessage: string | null = null;
if (errorCode === 'EMPTY') {
errorMessage = t('validation.empty');
} else if (errorCode === 'TOO_LONG') {
errorMessage = t('validation.too_long', { max: MAX_TAG_LENGTH });
} else if (errorCode === 'INVALID_CHARS') {
errorMessage = t('validation.invalid_chars');
}
setError(errorMessage);
return;
}
// Add to recent tags and suggestions
addRecentTag(selectedTag);
const domain = recipientEmails.map(extractDomain).find(Boolean);
if (domain) {
addTagSuggestion(domain, selectedTag);
}
onSelectTag(selectedTag);
setIsOpen(false);
setTag('');
setError(null);
};
const handleUseAddress = () => {
if (!tag) return;
handleSelectTag(tag);
};
return (
<div className="relative">
{/* Trigger Button */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsOpen(!isOpen)}
disabled={disabled}
title={t('button_tooltip')}
className="h-8 px-2"
>
<Plus className="w-4 h-4 mr-1" />
<Tag className="w-4 h-4" />
</Button>
{/* Popover */}
{isOpen && (
<div
ref={popoverRef}
className={cn(
'absolute top-full left-0 mt-1 z-50',
'bg-background border border-border rounded-lg shadow-lg',
'w-80 p-4 animate-in fade-in zoom-in-95 duration-150'
)}
>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-foreground">
{t('popover_title')}
</h3>
<button
onClick={() => setIsOpen(false)}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Tag Input */}
<div className="mb-3">
<Input
type="text"
value={tag}
onChange={(e) => handleTagChange(e.target.value)}
placeholder={t('tag_input_placeholder')}
className={cn(error && 'border-destructive')}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && tag && !error) {
e.preventDefault();
handleUseAddress();
}
}}
/>
{error && (
<p className="text-xs text-destructive mt-1">{error}</p>
)}
</div>
{/* Preview */}
<div className="mb-3 p-2 bg-muted rounded text-sm">
<div className="text-xs text-muted-foreground mb-1">
{t('preview_label')}
</div>
<div className="font-mono text-foreground break-all">
{preview}
</div>
</div>
{/* Recent Tags */}
{subAddress.recentTags.length > 0 && (
<div className="mb-3">
<div className="text-xs text-muted-foreground mb-2">
{t('recent_tags')}
</div>
<div className="flex flex-wrap gap-1">
{subAddress.recentTags.slice(0, 5).map((recentTag) => (
<button
key={recentTag}
onClick={() => handleSelectTag(recentTag)}
className="px-2 py-1 text-xs rounded bg-secondary hover:bg-accent text-foreground transition-colors"
>
{recentTag}
</button>
))}
</div>
</div>
)}
{/* Suggested Tags */}
{suggestions.length > 0 && (
<div className="mb-3">
<div className="text-xs text-muted-foreground mb-2">
{t('suggested_tags')}
</div>
<div className="flex flex-wrap gap-1">
{suggestions.map((suggestion) => (
<button
key={suggestion}
onClick={() => handleSelectTag(suggestion)}
className="px-2 py-1 text-xs rounded bg-primary/10 hover:bg-primary/20 text-primary transition-colors"
>
{suggestion}
</button>
))}
</div>
</div>
)}
{/* Help Text */}
<div className="mb-3 text-xs text-muted-foreground">
{t('help_text')}
</div>
{/* Use Address Button */}
<Button
onClick={handleUseAddress}
disabled={!tag || !!error}
className="w-full"
size="sm"
>
{t('use_address')}
</Button>
</div>
)}
</div>
);
}
+7 -3
View File
@@ -4,6 +4,7 @@ import { Menu, ArrowLeft, Plus, Search, X } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
interface MobileHeaderProps { interface MobileHeaderProps {
title: string; title: string;
@@ -22,6 +23,7 @@ export function MobileHeader({
onSearch, onSearch,
className, className,
}: MobileHeaderProps) { }: MobileHeaderProps) {
const t = useTranslations('sidebar');
const { toggleSidebar, goBack, sidebarOpen } = useUIStore(); const { toggleSidebar, goBack, sidebarOpen } = useUIStore();
const handleLeftAction = () => { const handleLeftAction = () => {
@@ -72,7 +74,7 @@ export function MobileHeader({
size="icon" size="icon"
onClick={onSearch} onClick={onSearch}
className="h-10 w-10" className="h-10 w-10"
aria-label="Search" aria-label={t('mobile.search')}
> >
<Search className="h-5 w-5" /> <Search className="h-5 w-5" />
</Button> </Button>
@@ -83,7 +85,7 @@ export function MobileHeader({
size="icon" size="icon"
onClick={onCompose} onClick={onCompose}
className="h-10 w-10 text-primary" className="h-10 w-10 text-primary"
aria-label="Compose" aria-label={t('mobile.compose')}
> >
<Plus className="h-5 w-5" /> <Plus className="h-5 w-5" />
</Button> </Button>
@@ -111,6 +113,8 @@ export function MobileViewerHeader({
onArchive: _onArchive, onArchive: _onArchive,
className, className,
}: MobileViewerHeaderProps) { }: MobileViewerHeaderProps) {
const t = useTranslations('sidebar');
return ( return (
<header <header
className={cn( className={cn(
@@ -124,7 +128,7 @@ export function MobileViewerHeader({
size="icon" size="icon"
onClick={onBack} onClick={onBack}
className="h-10 w-10" className="h-10 w-10"
aria-label="Go back" aria-label={t('mobile.go_back')}
> >
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
</Button> </Button>
+19 -1
View File
@@ -30,6 +30,7 @@ import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types"; import { Mailbox } from "@/lib/jmap/types";
import { useDragDropContext } from "@/contexts/drag-drop-context"; import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useMailboxDrop } from "@/hooks/use-mailbox-drop"; import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
import { toast } from "@/stores/toast-store";
interface SidebarProps { interface SidebarProps {
mailboxes: Mailbox[]; mailboxes: Mailbox[];
@@ -96,6 +97,7 @@ function MailboxTreeItem({
isCollapsed: boolean; isCollapsed: boolean;
}) { }) {
const t = useTranslations('sidebar'); const t = useTranslations('sidebar');
const tNotifications = useTranslations('notifications');
const hasChildren = node.children.length > 0; const hasChildren = node.children.length > 0;
const isExpanded = expandedFolders.has(node.id); const isExpanded = expandedFolders.has(node.id);
const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id); const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id);
@@ -106,6 +108,22 @@ function MailboxTreeItem({
const { isDragging: globalDragging } = useDragDropContext(); const { isDragging: globalDragging } = useDragDropContext();
const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({ const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({
mailbox: node, mailbox: node,
onSuccess: (count, mailboxName) => {
if (count === 1) {
toast.success(
tNotifications('email_moved'),
tNotifications('moved_to_mailbox', { mailbox: mailboxName })
);
} else {
toast.success(
tNotifications('emails_moved', { count }),
tNotifications('moved_to_mailbox', { mailbox: mailboxName })
);
}
},
onError: () => {
toast.error(tNotifications('move_failed'), tNotifications('move_error'));
},
}); });
return ( return (
@@ -371,7 +389,7 @@ export function Sidebar({
onClearSearch?.(); onClearSearch?.();
}} }}
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear search" aria-label={t('clear_search')}
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
+12
View File
@@ -5,11 +5,23 @@ import { NextIntlClientProvider } from 'next-intl';
import { useLocaleStore } from '@/stores/locale-store'; import { useLocaleStore } from '@/stores/locale-store';
import enMessages from '@/locales/en/common.json'; import enMessages from '@/locales/en/common.json';
import frMessages from '@/locales/fr/common.json'; import frMessages from '@/locales/fr/common.json';
import jaMessages from '@/locales/ja/common.json';
import esMessages from '@/locales/es/common.json';
import itMessages from '@/locales/it/common.json';
import deMessages from '@/locales/de/common.json';
import nlMessages from '@/locales/nl/common.json';
import ptMessages from '@/locales/pt/common.json';
// Pre-loaded translations (loaded at build time, not runtime) // Pre-loaded translations (loaded at build time, not runtime)
const ALL_MESSAGES = { const ALL_MESSAGES = {
en: enMessages, en: enMessages,
fr: frMessages, fr: frMessages,
ja: jaMessages,
es: esMessages,
it: itMessages,
de: deMessages,
nl: nlMessages,
pt: ptMessages,
}; };
interface IntlProviderProps { interface IntlProviderProps {
+55
View File
@@ -0,0 +1,55 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { SettingsSection, SettingItem } from './settings-section';
import { IdentityManagerModal } from '@/components/identity/identity-manager-modal';
import { useIdentityStore } from '@/stores/identity-store';
export function IdentitySettings() {
const t = useTranslations('settings.identities');
const { identities } = useIdentityStore();
const [showModal, setShowModal] = useState(false);
return (
<>
<SettingsSection title={t('title')} description={t('description')}>
{/* Identity Count */}
<SettingItem
label={t('identities_count.label')}
description={t('identities_count.description')}
>
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">
{identities.length === 0
? t('identities_count.count_zero')
: identities.length === 1
? t('identities_count.count_one')
: t('identities_count.count_other', { count: identities.length })}
</span>
<Button onClick={() => setShowModal(true)} size="sm">
{t('manage')}
</Button>
</div>
</SettingItem>
{/* Sub-Addressing Info */}
<SettingItem
label={t('sub_addressing.label')}
description={t('sub_addressing.description')}
>
<Button variant="outline" size="sm" onClick={() => setShowModal(true)}>
{t('sub_addressing.learn_more')}
</Button>
</SettingItem>
</SettingsSection>
{/* Identity Manager Modal */}
<IdentityManagerModal
isOpen={showModal}
onClose={() => setShowModal(false)}
/>
</>
);
}
+2 -1
View File
@@ -111,7 +111,8 @@ export function Select({ value, onChange, options }: SelectProps) {
<select <select
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className="px-3 py-1.5 text-sm rounded bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary" dir="auto"
className="px-3 py-1.5 text-sm rounded bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary cursor-pointer"
> >
{options.map((option) => ( {options.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
+15 -36
View File
@@ -1,52 +1,31 @@
"use client"; "use client";
import { useLocale, useTranslations } from 'next-intl'; import { useLocale } from 'next-intl';
import { cn } from '@/lib/utils';
import { useLocaleStore } from '@/stores/locale-store'; import { useLocaleStore } from '@/stores/locale-store';
import { Select } from '@/components/settings/settings-section';
export function LanguageSwitcher({ className }: { className?: string }) { export function LanguageSwitcher({ className }: { className?: string }) {
const currentLocale = useLocale(); const currentLocale = useLocale();
const t = useTranslations('language');
const setLocale = useLocaleStore((state) => state.setLocale); const setLocale = useLocaleStore((state) => state.setLocale);
const handleLanguageChange = (newLocale: string) => {
if (newLocale === currentLocale) return;
// Update locale in store (persisted to localStorage via Zustand)
// IntlProvider handles the translation switch
setLocale(newLocale);
};
const languages = [ const languages = [
{ value: 'en', label: 'English' }, { value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' } { value: 'fr', label: 'Français' },
{ value: 'ja', label: '日本語' },
{ value: 'es', label: 'Español' },
{ value: 'it', label: 'Italiano' },
{ value: 'de', label: 'Deutsch' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'pt', label: 'Português' }
]; ];
return ( return (
<div <div className={className}>
className={cn("flex gap-2", className)} <Select
role="radiogroup" value={currentLocale}
aria-label={t('select_language')} onChange={setLocale}
> options={languages}
{languages.map((lang) => ( />
<button
key={lang.value}
type="button"
role="radio"
aria-checked={currentLocale === lang.value}
aria-label={t(lang.value === 'en' ? 'switch_to_english' : 'switch_to_french')}
onClick={() => handleLanguageChange(lang.value)}
className={cn(
"px-3 py-1.5 text-xs rounded transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
currentLocale === lang.value
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-accent text-foreground"
)}
>
{lang.label}
</button>
))}
</div> </div>
); );
} }
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef } from 'react';
interface UseFocusTrapOptions {
isActive: boolean;
onEscape?: () => void;
restoreFocus?: boolean;
}
export function useFocusTrap({
isActive,
onEscape,
restoreFocus = true,
}: UseFocusTrapOptions) {
const containerRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isActive || !containerRef.current) return;
// Store the element that had focus before modal opened
previousActiveElement.current = document.activeElement as HTMLElement;
const container = containerRef.current;
// Get all focusable elements
const getFocusableElements = () => {
return container.querySelectorAll<HTMLElement>(
'button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"]):not(:disabled)'
);
};
// Focus first element
const focusableElements = getFocusableElements();
const firstElement = focusableElements[0];
if (firstElement) {
firstElement.focus();
}
// Handle Tab key to trap focus
const handleKeyDown = (e: KeyboardEvent) => {
// Handle Escape
if (e.key === 'Escape' && onEscape) {
onEscape();
return;
}
// Handle Tab
if (e.key === 'Tab') {
const focusableElements = getFocusableElements();
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
// Shift+Tab
if (document.activeElement === firstElement) {
lastElement?.focus();
e.preventDefault();
}
} else {
// Tab
if (document.activeElement === lastElement) {
firstElement?.focus();
e.preventDefault();
}
}
}
};
container.addEventListener('keydown', handleKeyDown);
// Cleanup
return () => {
container.removeEventListener('keydown', handleKeyDown);
// Restore focus to previous element
if (restoreFocus && previousActiveElement.current) {
previousActiveElement.current.focus();
}
};
}, [isActive, onEscape, restoreFocus]);
return containerRef;
}
+9
View File
@@ -19,6 +19,7 @@ export interface KeyboardShortcutHandlers {
onDelete?: () => void; onDelete?: () => void;
onMarkAsUnread?: () => void; onMarkAsUnread?: () => void;
onMarkAsRead?: () => void; onMarkAsRead?: () => void;
onToggleSpam?: () => void;
// Global actions // Global actions
onCompose?: () => void; onCompose?: () => void;
@@ -180,6 +181,13 @@ export function useKeyboardShortcuts({
} }
break; break;
case "!":
if (selectedEmailId) {
event.preventDefault();
h.onToggleSpam?.();
}
break;
// Global actions // Global actions
case "c": case "c":
event.preventDefault(); event.preventDefault();
@@ -264,6 +272,7 @@ export const KEYBOARD_SHORTCUTS = {
{ key: "# / Del", description: "shortcuts.actions.delete" }, { key: "# / Del", description: "shortcuts.actions.delete" },
{ key: "u", description: "shortcuts.actions.mark_unread" }, { key: "u", description: "shortcuts.actions.mark_unread" },
{ key: "Shift + I", description: "shortcuts.actions.mark_read" }, { key: "Shift + I", description: "shortcuts.actions.mark_read" },
{ key: "!", description: "shortcuts.actions.toggle_spam" },
], ],
global: [ global: [
{ key: "c", description: "shortcuts.global.compose" }, { key: "c", description: "shortcuts.global.compose" },
+22 -7
View File
@@ -10,6 +10,9 @@ import { toast } from "@/stores/toast-store";
interface UseMailboxDropOptions { interface UseMailboxDropOptions {
mailbox: Mailbox; mailbox: Mailbox;
onDropComplete?: () => void; onDropComplete?: () => void;
// Translation callbacks for toast messages
onSuccess?: (count: number, mailboxName: string) => void;
onError?: (error: string) => void;
} }
interface UseMailboxDropReturn { interface UseMailboxDropReturn {
@@ -24,7 +27,7 @@ interface UseMailboxDropReturn {
isInvalidDropTarget: boolean; isInvalidDropTarget: boolean;
} }
export function useMailboxDrop({ mailbox, onDropComplete }: UseMailboxDropOptions): UseMailboxDropReturn { export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn {
const [isOver, setIsOver] = useState(false); const [isOver, setIsOver] = useState(false);
const { client } = useAuthStore(); const { client } = useAuthStore();
const { moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox } = useEmailStore(); const { moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox } = useEmailStore();
@@ -119,21 +122,33 @@ export function useMailboxDrop({ mailbox, onDropComplete }: UseMailboxDropOption
// Refresh the current mailbox view // Refresh the current mailbox view
await fetchEmails(client, selectedMailbox); await fetchEmails(client, selectedMailbox);
// Show success toast // Call success callback if provided, otherwise use fallback
if (emailIds.length === 1) { if (onSuccess) {
toast.success("Email moved", `Moved to ${mailbox.name}`); onSuccess(emailIds.length, mailbox.name);
} else { } else {
toast.success("Emails moved", `${emailIds.length} emails moved to ${mailbox.name}`); // Fallback for backward compatibility
if (emailIds.length === 1) {
toast.success("Email moved", `Moved to ${mailbox.name}`);
} else {
toast.success("Emails moved", `${emailIds.length} emails moved to ${mailbox.name}`);
}
} }
onDropComplete?.(); onDropComplete?.();
} catch (error) { } catch (error) {
console.error("Failed to move emails:", error); console.error("Failed to move emails:", error);
toast.error("Move failed", "Could not move emails to the selected folder");
// Call error callback if provided, otherwise use fallback
if (onError) {
onError(error instanceof Error ? error.message : 'Unknown error');
} else {
// Fallback for backward compatibility
toast.error("Move failed", "Could not move emails to the selected folder");
}
} finally { } finally {
endDrag(); endDrag();
} }
}, [client, mailbox, isValidTarget, moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete]); }, [client, mailbox, isValidTarget, moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete, onSuccess, onError]);
const valid = isValidTarget(); const valid = isValidTarget();
+296
View File
@@ -0,0 +1,296 @@
import { describe, it, expect } from 'vitest';
import {
parseColor,
getLuminance,
isDarkColor,
transformColorForDarkMode,
transformInlineStyles,
} from '../color-transform';
describe('parseColor', () => {
describe('hex colors', () => {
it('should parse 6-digit hex colors', () => {
expect(parseColor('#333333')).toEqual({ r: 51, g: 51, b: 51 });
expect(parseColor('#AABBCC')).toEqual({ r: 170, g: 187, b: 204 });
expect(parseColor('#ffffff')).toEqual({ r: 255, g: 255, b: 255 });
});
it('should parse 3-digit hex colors', () => {
expect(parseColor('#FFF')).toEqual({ r: 255, g: 255, b: 255 });
expect(parseColor('#ABC')).toEqual({ r: 170, g: 187, b: 204 });
expect(parseColor('#000')).toEqual({ r: 0, g: 0, b: 0 });
});
it('should handle uppercase and lowercase', () => {
expect(parseColor('#aabbcc')).toEqual(parseColor('#AABBCC'));
expect(parseColor('#fff')).toEqual(parseColor('#FFF'));
});
});
describe('rgb/rgba colors', () => {
it('should parse rgb colors', () => {
expect(parseColor('rgb(51, 51, 51)')).toEqual({ r: 51, g: 51, b: 51 });
expect(parseColor('rgb(255, 0, 0)')).toEqual({ r: 255, g: 0, b: 0 });
});
it('should parse rgba colors', () => {
expect(parseColor('rgba(51, 51, 51, 0.5)')).toEqual({ r: 51, g: 51, b: 51, a: 0.5 });
expect(parseColor('rgba(0, 0, 0, 0.8)')).toEqual({ r: 0, g: 0, b: 0, a: 0.8 });
});
it('should handle spaces in rgb/rgba', () => {
expect(parseColor('rgb(51,51,51)')).toEqual({ r: 51, g: 51, b: 51 });
expect(parseColor('rgba(51, 51, 51, 0.5)')).toEqual({ r: 51, g: 51, b: 51, a: 0.5 });
});
});
describe('hsl/hsla colors', () => {
it('should parse hsl colors', () => {
const result = parseColor('hsl(0, 0%, 20%)');
expect(result).toEqual({ r: 51, g: 51, b: 51 });
});
it('should parse hsla colors', () => {
const result = parseColor('hsla(0, 0%, 20%, 0.5)');
expect(result).toEqual({ r: 51, g: 51, b: 51, a: 0.5 });
});
it('should convert hsl to rgb correctly', () => {
const red = parseColor('hsl(0, 100%, 50%)');
expect(red).toEqual({ r: 255, g: 0, b: 0 });
const green = parseColor('hsl(120, 100%, 50%)');
expect(green).toEqual({ r: 0, g: 255, b: 0 });
const blue = parseColor('hsl(240, 100%, 50%)');
expect(blue).toEqual({ r: 0, g: 0, b: 255 });
});
});
describe('named colors', () => {
it('should parse named colors', () => {
expect(parseColor('black')).toEqual({ r: 0, g: 0, b: 0 });
expect(parseColor('white')).toEqual({ r: 255, g: 255, b: 255 });
expect(parseColor('red')).toEqual({ r: 255, g: 0, b: 0 });
expect(parseColor('green')).toEqual({ r: 0, g: 128, b: 0 });
expect(parseColor('blue')).toEqual({ r: 0, g: 0, b: 255 });
});
it('should handle transparent', () => {
expect(parseColor('transparent')).toEqual({ r: 0, g: 0, b: 0, a: 0 });
});
});
describe('edge cases', () => {
it('should return null for invalid colors', () => {
expect(parseColor('invalid')).toBeNull();
expect(parseColor('#GGGGGG')).toBeNull();
expect(parseColor('rgb(300, 400, 500)')).toBeNull();
});
it('should return null for inherit and currentColor', () => {
expect(parseColor('inherit')).toBeNull();
expect(parseColor('currentColor')).toBeNull();
expect(parseColor('currentcolor')).toBeNull();
});
it('should handle empty or invalid input', () => {
expect(parseColor('')).toBeNull();
expect(parseColor(' ')).toBeNull();
});
});
});
describe('getLuminance', () => {
it('should calculate luminance for black', () => {
expect(getLuminance(0, 0, 0)).toBe(0);
});
it('should calculate luminance for white', () => {
expect(getLuminance(255, 255, 255)).toBe(1);
});
it('should calculate luminance for gray', () => {
const luminance = getLuminance(128, 128, 128);
expect(luminance).toBeGreaterThan(0);
expect(luminance).toBeLessThan(1);
expect(luminance).toBeCloseTo(0.215, 2);
});
it('should calculate luminance for dark colors', () => {
const darkGray = getLuminance(51, 51, 51);
expect(darkGray).toBeLessThan(0.5);
});
it('should calculate luminance for light colors', () => {
const lightGray = getLuminance(200, 200, 200);
expect(lightGray).toBeGreaterThan(0.5);
});
});
describe('isDarkColor', () => {
it('should identify dark colors', () => {
expect(isDarkColor('#000000')).toBe(true);
expect(isDarkColor('#111111')).toBe(true);
expect(isDarkColor('#333333')).toBe(true);
expect(isDarkColor('rgb(51, 51, 51)')).toBe(true);
});
it('should identify light colors', () => {
expect(isDarkColor('#ffffff')).toBe(false);
expect(isDarkColor('#eeeeee')).toBe(false);
expect(isDarkColor('rgb(200, 200, 200)')).toBe(false);
});
it('should return false for invalid colors', () => {
expect(isDarkColor('invalid')).toBe(false);
expect(isDarkColor('inherit')).toBe(false);
});
});
describe('transformColorForDarkMode', () => {
it('should lighten very dark colors', () => {
const original = '#111111';
const transformed = transformColorForDarkMode(original);
const originalRgb = parseColor(original)!;
const transformedRgb = parseColor(transformed)!;
expect(transformedRgb.r).toBeGreaterThan(originalRgb.r);
expect(transformedRgb.g).toBeGreaterThan(originalRgb.g);
expect(transformedRgb.b).toBeGreaterThan(originalRgb.b);
});
it('should transform #333333 to a lighter color', () => {
const transformed = transformColorForDarkMode('#333333');
const rgb = parseColor(transformed)!;
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
expect(luminance).toBeGreaterThan(0.4);
});
it('should preserve already light colors', () => {
const lightColors = ['#eeeeee', '#ffffff', 'rgb(200, 200, 200)'];
lightColors.forEach((color) => {
const original = parseColor(color)!;
const transformed = parseColor(transformColorForDarkMode(color))!;
const originalLum = getLuminance(original.r, original.g, original.b);
const transformedLum = getLuminance(transformed.r, transformed.g, transformed.b);
expect(transformedLum).toBeGreaterThanOrEqual(originalLum * 0.9);
});
});
it('should handle rgba colors with alpha', () => {
const original = 'rgba(51, 51, 51, 0.8)';
const transformed = transformColorForDarkMode(original);
expect(transformed).toContain('rgba');
expect(transformed).toContain('0.8');
});
it('should preserve nearly transparent colors', () => {
const original = 'rgba(0, 0, 0, 0.05)';
const transformed = transformColorForDarkMode(original);
expect(transformed).toBe(original);
});
it('should handle invalid colors gracefully', () => {
expect(transformColorForDarkMode('invalid')).toBe('invalid');
expect(transformColorForDarkMode('inherit')).toBe('inherit');
});
it('should lighten medium darkness colors', () => {
const original = '#646463';
const transformed = transformColorForDarkMode(original);
const originalRgb = parseColor(original)!;
const transformedRgb = parseColor(transformed)!;
expect(transformedRgb.r).toBeGreaterThan(originalRgb.r);
expect(transformedRgb.g).toBeGreaterThan(originalRgb.g);
expect(transformedRgb.b).toBeGreaterThan(originalRgb.b);
});
});
describe('transformInlineStyles', () => {
it('should not transform styles in light mode', () => {
const original = 'color: #333333; font-size: 16px';
expect(transformInlineStyles(original, 'light')).toBe(original);
});
it('should transform color property in dark mode', () => {
const original = 'color: #333333';
const transformed = transformInlineStyles(original, 'dark');
expect(transformed).not.toBe(original);
expect(transformed).toContain('color:');
expect(transformed).toContain('rgb(');
});
it('should transform background-color property', () => {
const original = 'background-color: #111111';
const transformed = transformInlineStyles(original, 'dark');
expect(transformed).not.toBe(original);
expect(transformed).toContain('background-color:');
});
it('should preserve non-color properties', () => {
const original = 'color: #333333; font-size: 16px; margin: 10px';
const transformed = transformInlineStyles(original, 'dark');
expect(transformed).toContain('font-size: 16px');
expect(transformed).toContain('margin: 10px');
});
it('should handle multiple color properties', () => {
const original = 'color: #111111; background-color: #222222; font-weight: bold';
const transformed = transformInlineStyles(original, 'dark');
expect(transformed).toContain('color:');
expect(transformed).toContain('background-color:');
expect(transformed).toContain('font-weight: bold');
});
it('should preserve !important declarations', () => {
const original = 'color: #333333 !important';
const transformed = transformInlineStyles(original, 'dark');
expect(transformed).toContain('!important');
});
it('should handle empty or invalid styles', () => {
expect(transformInlineStyles('', 'dark')).toBe('');
expect(transformInlineStyles('invalid', 'dark')).toBe('invalid');
});
it('should transform the James Clear email colors', () => {
const original = 'color: #333333; font-family: Georgia; font-size: 16px';
const transformed = transformInlineStyles(original, 'dark');
expect(transformed).toContain('font-family: Georgia');
expect(transformed).toContain('font-size: 16px');
const colorMatch = transformed.match(/color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
expect(colorMatch).not.toBeNull();
if (colorMatch) {
const [, r, g, b] = colorMatch.map(Number);
expect(r).toBeGreaterThan(51);
expect(g).toBeGreaterThan(51);
expect(b).toBeGreaterThan(51);
}
});
it('should handle background shorthand with color', () => {
const original = 'background: #333333';
const transformed = transformInlineStyles(original, 'dark');
expect(transformed).not.toBe(original);
expect(transformed).toContain('background:');
});
it('should not transform background with url', () => {
const original = 'background: url(image.jpg) #333333';
const transformed = transformInlineStyles(original, 'dark');
expect(transformed).toBe(original);
});
it('should transform border-color', () => {
const original = 'border-color: #111111';
const transformed = transformInlineStyles(original, 'dark');
expect(transformed).not.toBe(original);
expect(transformed).toContain('border-color:');
});
});
+195
View File
@@ -0,0 +1,195 @@
import { describe, it, expect } from 'vitest';
import {
sanitizeEmailHtml,
sanitizeSignatureHtml,
parseHtmlSafely,
hasRichFormatting,
} from '../email-sanitization';
describe('email-sanitization', () => {
describe('sanitizeEmailHtml', () => {
it('should remove script tags', () => {
const malicious = '<p>Hello</p><script>alert("XSS")</script>';
const clean = sanitizeEmailHtml(malicious);
expect(clean).not.toContain('<script>');
expect(clean).toContain('Hello');
});
it('should remove event handlers', () => {
const malicious = '<img src="x" onerror="alert(\'XSS\')">';
const clean = sanitizeEmailHtml(malicious);
expect(clean).not.toContain('onerror');
});
it('should remove iframe, object, embed tags', () => {
const malicious = '<div>Content</div><iframe src="evil.com"></iframe><object></object>';
const clean = sanitizeEmailHtml(malicious);
expect(clean).not.toContain('<iframe');
expect(clean).not.toContain('<object');
expect(clean).toContain('Content');
});
it('should remove meta, link, base tags', () => {
const malicious = '<p>Text</p><meta charset="utf-8"><link rel="stylesheet" href="evil.css">';
const clean = sanitizeEmailHtml(malicious);
expect(clean).not.toContain('<meta');
expect(clean).not.toContain('<link');
expect(clean).toContain('Text');
});
it('should preserve safe HTML structure', () => {
const safe = '<p>Paragraph</p><div><span>Nested</span></div><table><tr><td>Cell</td></tr></table>';
const clean = sanitizeEmailHtml(safe);
expect(clean).toContain('<p>');
expect(clean).toContain('<div>');
expect(clean).toContain('<table>');
expect(clean).toContain('Cell');
});
it('should preserve safe attributes', () => {
const withAttrs = '<p style="color: red;" class="text">Styled</p>';
const clean = sanitizeEmailHtml(withAttrs);
expect(clean).toContain('style');
expect(clean).toContain('class');
});
it('should handle empty input', () => {
expect(sanitizeEmailHtml('')).toBe('');
expect(sanitizeEmailHtml(' ')).toBeTruthy();
});
it('should handle malformed HTML', () => {
const malformed = '<p>Unclosed<div>Tags';
const clean = sanitizeEmailHtml(malformed);
expect(clean).toContain('Unclosed');
expect(clean).toContain('Tags');
});
});
describe('sanitizeSignatureHtml', () => {
it('should allow basic formatting tags', () => {
const signature = '<p><strong>John Doe</strong><br><em>Software Engineer</em></p>';
const clean = sanitizeSignatureHtml(signature);
expect(clean).toContain('<strong>');
expect(clean).toContain('<em>');
expect(clean).toContain('John Doe');
});
it('should remove images from signatures', () => {
const signature = '<p>John</p><img src="logo.png" alt="Logo">';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('<img');
expect(clean).toContain('John');
});
it('should remove video and audio tags', () => {
const signature = '<p>John</p><video src="vid.mp4"></video><audio src="sound.mp3"></audio>';
const clean = sanitizeSignatureHtml(signature);
expect(clean).not.toContain('<video');
expect(clean).not.toContain('<audio');
});
it('should preserve links with safe attributes', () => {
const signature = '<p><a href="https://example.com" style="color: blue;">Website</a></p>';
const clean = sanitizeSignatureHtml(signature);
expect(clean).toContain('<a');
expect(clean).toContain('href');
expect(clean).toContain('example.com');
});
it('should remove script tags', () => {
const malicious = '<p>Signature</p><script>alert("XSS")</script>';
const clean = sanitizeSignatureHtml(malicious);
expect(clean).not.toContain('<script>');
expect(clean).toContain('Signature');
});
it('should handle empty signatures', () => {
expect(sanitizeSignatureHtml('')).toBe('');
expect(sanitizeSignatureHtml(' ')).toBe('');
});
it('should be stricter than email sanitization', () => {
const html = '<p>Text</p><img src="pic.jpg"><table><tr><td>Data</td></tr></table>';
const emailClean = sanitizeEmailHtml(html);
const signatureClean = sanitizeSignatureHtml(html);
// Email allows img and table
expect(emailClean).toContain('<img');
expect(emailClean).toContain('<table>');
// Signature blocks img but may allow some tables (verify in implementation)
expect(signatureClean).not.toContain('<img');
});
});
describe('parseHtmlSafely', () => {
it('should return a valid Document', () => {
const html = '<p>Test</p>';
const doc = parseHtmlSafely(html);
expect(doc).toBeInstanceOf(Document);
});
it('should not execute scripts', () => {
let executed = false;
const html = '<script>executed = true;</script>';
parseHtmlSafely(html);
expect(executed).toBe(false);
});
it('should handle malformed HTML gracefully', () => {
const malformed = '<p>Unclosed<div>Tags';
const doc = parseHtmlSafely(malformed);
expect(doc).toBeInstanceOf(Document);
expect(doc.body.textContent).toContain('Unclosed');
});
});
describe('hasRichFormatting', () => {
it('should detect tables', () => {
const html = '<table><tr><td>Data</td></tr></table>';
expect(hasRichFormatting(html)).toBe(true);
});
it('should detect images', () => {
const html = '<img src="pic.jpg">';
expect(hasRichFormatting(html)).toBe(true);
});
it('should detect inline styles', () => {
const html = '<div style="color: red;">Styled</div>';
expect(hasRichFormatting(html)).toBe(true);
});
it('should detect formatting tags', () => {
expect(hasRichFormatting('<b>Bold</b>')).toBe(true);
expect(hasRichFormatting('<strong>Strong</strong>')).toBe(true);
expect(hasRichFormatting('<em>Emphasized</em>')).toBe(true);
});
it('should detect headings', () => {
expect(hasRichFormatting('<h1>Title</h1>')).toBe(true);
expect(hasRichFormatting('<h3>Subtitle</h3>')).toBe(true);
});
it('should detect lists', () => {
expect(hasRichFormatting('<ul><li>Item</li></ul>')).toBe(true);
expect(hasRichFormatting('<ol><li>Item</li></ol>')).toBe(true);
});
it('should return false for plain text', () => {
const plain = '<p>Just plain text</p>';
expect(hasRichFormatting(plain)).toBe(false);
});
it('should return false for simple paragraphs', () => {
const simple = '<p>Line 1</p><p>Line 2</p>';
expect(hasRichFormatting(simple)).toBe(false);
});
it('should handle empty HTML', () => {
expect(hasRichFormatting('')).toBe(false);
expect(hasRichFormatting(' ')).toBe(false);
});
});
});
+361
View File
@@ -0,0 +1,361 @@
import { describe, it, expect } from 'vitest';
import {
isValidEmail,
validateEmailList,
getEmailValidationError,
isValidUnsubscribeUrl,
parseUnsubscribeUrls,
} from '../validation';
describe('validation', () => {
describe('isValidEmail', () => {
it('should accept valid basic emails', () => {
expect(isValidEmail('user@example.com')).toBe(true);
expect(isValidEmail('john.doe@company.co.uk')).toBe(true);
expect(isValidEmail('test_user@subdomain.example.com')).toBe(true);
});
it('should accept emails with plus addressing', () => {
expect(isValidEmail('user+tag@example.com')).toBe(true);
expect(isValidEmail('user+shopping@example.com')).toBe(true);
});
it('should accept various valid formats', () => {
expect(isValidEmail('a@b.co')).toBe(true);
expect(isValidEmail('user123@test-domain.com')).toBe(true);
expect(isValidEmail('first.last+tag@example.co.uk')).toBe(true);
});
it('should reject emails without @ symbol', () => {
expect(isValidEmail('userexample.com')).toBe(false);
expect(isValidEmail('user')).toBe(false);
});
it('should reject emails without domain', () => {
expect(isValidEmail('user@')).toBe(false);
expect(isValidEmail('@example.com')).toBe(false);
});
it('should reject header injection attempts', () => {
expect(isValidEmail('test\r\nBcc:evil@example.com')).toBe(false);
expect(isValidEmail('test\rBcc:evil@example.com')).toBe(false);
expect(isValidEmail('test\nBcc:evil@example.com')).toBe(false);
});
it('should reject emails with dangerous characters', () => {
expect(isValidEmail('test<script>@example.com')).toBe(false);
expect(isValidEmail('test>evil@example.com')).toBe(false);
expect(isValidEmail('test@evil>.com')).toBe(false);
});
it('should reject overly long emails', () => {
const longLocal = 'a'.repeat(256);
expect(isValidEmail(`${longLocal}@example.com`)).toBe(false);
});
it('should reject emails with local part > 64 chars', () => {
const longLocal = 'a'.repeat(65);
expect(isValidEmail(`${longLocal}@example.com`)).toBe(false);
});
it('should reject emails with domain > 255 chars', () => {
const longDomain = 'a'.repeat(256) + '.com';
expect(isValidEmail(`user@${longDomain}`)).toBe(false);
});
it('should reject domains starting or ending with dot', () => {
expect(isValidEmail('user@.example.com')).toBe(false);
expect(isValidEmail('user@example.com.')).toBe(false);
});
it('should reject domains with consecutive dots', () => {
expect(isValidEmail('user@example..com')).toBe(false);
expect(isValidEmail('user@sub..domain.com')).toBe(false);
});
it('should reject empty or null input', () => {
expect(isValidEmail('')).toBe(false);
expect(isValidEmail(' ')).toBe(false);
});
it('should handle edge cases', () => {
expect(isValidEmail('user@localhost')).toBe(true); // Valid per RFC
expect(isValidEmail('user@192.168.1.1')).toBe(true); // IP address domain
});
});
describe('validateEmailList', () => {
it('should validate single valid email', () => {
const result = validateEmailList('user@example.com');
expect(result.valid).toBe(true);
expect(result.invalidEmails).toEqual([]);
});
it('should validate multiple valid emails', () => {
const result = validateEmailList('user1@example.com, user2@test.com, user3@domain.co.uk');
expect(result.valid).toBe(true);
expect(result.invalidEmails).toEqual([]);
});
it('should handle whitespace around emails', () => {
const result = validateEmailList(' user1@example.com , user2@test.com ');
expect(result.valid).toBe(true);
expect(result.invalidEmails).toEqual([]);
});
it('should reject list with one invalid email', () => {
const result = validateEmailList('user1@example.com, invalid-email, user3@domain.com');
expect(result.valid).toBe(false);
expect(result.invalidEmails).toEqual(['invalid-email']);
});
it('should identify all invalid emails', () => {
const result = validateEmailList('user1@example.com, bad1, user2@test.com, bad2@');
expect(result.valid).toBe(false);
expect(result.invalidEmails).toContain('bad1');
expect(result.invalidEmails).toContain('bad2@');
expect(result.invalidEmails).toHaveLength(2);
});
it('should handle empty string', () => {
const result = validateEmailList('');
expect(result.valid).toBe(true);
expect(result.invalidEmails).toEqual([]);
});
it('should handle whitespace-only string', () => {
const result = validateEmailList(' ');
expect(result.valid).toBe(true);
expect(result.invalidEmails).toEqual([]);
});
it('should filter out empty entries from commas', () => {
const result = validateEmailList('user1@example.com,,user2@test.com,');
expect(result.valid).toBe(true);
expect(result.invalidEmails).toEqual([]);
});
});
describe('getEmailValidationError', () => {
it('should return null for valid email', () => {
expect(getEmailValidationError('user@example.com')).toBeNull();
expect(getEmailValidationError('user+tag@example.com')).toBeNull();
});
it('should return error for empty email', () => {
const error = getEmailValidationError('');
expect(error).not.toBeNull();
expect(error).toContain('required');
});
it('should return error for whitespace-only email', () => {
const error = getEmailValidationError(' ');
expect(error).not.toBeNull();
expect(error).toContain('required');
});
it('should return error for overly long email', () => {
const longEmail = 'a'.repeat(256) + '@example.com';
const error = getEmailValidationError(longEmail);
expect(error).not.toBeNull();
expect(error).toContain('too long');
expect(error).toContain('254');
});
it('should return error for dangerous characters', () => {
const error = getEmailValidationError('test\r\nBcc:evil@example.com');
expect(error).not.toBeNull();
expect(error).toContain('invalid characters');
});
it('should return error for invalid format', () => {
const error = getEmailValidationError('not-an-email');
expect(error).not.toBeNull();
expect(error).toContain('valid email');
});
it('should provide user-friendly messages', () => {
const error1 = getEmailValidationError('test@');
const error2 = getEmailValidationError('@example.com');
const error3 = getEmailValidationError('no-at-sign');
expect(error1).toContain('valid');
expect(error2).toContain('valid');
expect(error3).toContain('valid');
});
});
describe('isValidUnsubscribeUrl', () => {
describe('HTTP/HTTPS URLs', () => {
it('should accept valid HTTP URLs', () => {
expect(isValidUnsubscribeUrl('http://example.com/unsubscribe')).toBe(true);
expect(isValidUnsubscribeUrl('http://newsletter.example.com/unsub?id=123')).toBe(true);
});
it('should accept valid HTTPS URLs', () => {
expect(isValidUnsubscribeUrl('https://example.com/unsubscribe')).toBe(true);
expect(isValidUnsubscribeUrl('https://example.com/unsub?token=abc123')).toBe(true);
expect(isValidUnsubscribeUrl('https://sub.domain.com/unsubscribe')).toBe(true);
});
it('should accept URLs with paths and query params', () => {
expect(isValidUnsubscribeUrl('https://example.com/path/to/unsub?id=123&token=abc')).toBe(true);
expect(isValidUnsubscribeUrl('http://example.com/unsub#section')).toBe(true);
});
});
describe('mailto URLs', () => {
it('should accept valid mailto URLs', () => {
expect(isValidUnsubscribeUrl('mailto:unsubscribe@example.com')).toBe(true);
expect(isValidUnsubscribeUrl('mailto:unsub@newsletter.com')).toBe(true);
});
it('should accept mailto with query params', () => {
expect(isValidUnsubscribeUrl('mailto:unsub@example.com?subject=Unsubscribe')).toBe(true);
expect(isValidUnsubscribeUrl('mailto:unsub@example.com?subject=Remove&body=Please%20remove')).toBe(true);
});
it('should reject mailto with invalid email', () => {
expect(isValidUnsubscribeUrl('mailto:invalid-email')).toBe(false);
expect(isValidUnsubscribeUrl('mailto:@example.com')).toBe(false);
expect(isValidUnsubscribeUrl('mailto:user@')).toBe(false);
});
});
describe('XSS attack vectors', () => {
it('should reject javascript: protocol', () => {
expect(isValidUnsubscribeUrl('javascript:alert(1)')).toBe(false);
expect(isValidUnsubscribeUrl('javascript:alert(document.cookie)')).toBe(false);
expect(isValidUnsubscribeUrl('javascript:void(0)')).toBe(false);
});
it('should reject data: protocol', () => {
expect(isValidUnsubscribeUrl('data:text/html,<script>alert(1)</script>')).toBe(false);
expect(isValidUnsubscribeUrl('data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==')).toBe(false);
});
it('should reject file: protocol', () => {
expect(isValidUnsubscribeUrl('file:///etc/passwd')).toBe(false);
expect(isValidUnsubscribeUrl('file://C:/Windows/System32/config')).toBe(false);
});
it('should reject vbscript: protocol', () => {
expect(isValidUnsubscribeUrl('vbscript:msgbox(1)')).toBe(false);
});
it('should reject about: protocol', () => {
expect(isValidUnsubscribeUrl('about:blank')).toBe(false);
});
it('should reject ftp: protocol', () => {
expect(isValidUnsubscribeUrl('ftp://example.com/file')).toBe(false);
});
});
describe('edge cases', () => {
it('should reject empty or null input', () => {
expect(isValidUnsubscribeUrl('')).toBe(false);
expect(isValidUnsubscribeUrl(' ')).toBe(false);
});
it('should reject malformed URLs', () => {
expect(isValidUnsubscribeUrl('not-a-url')).toBe(false);
expect(isValidUnsubscribeUrl('example.com/unsub')).toBe(false);
expect(isValidUnsubscribeUrl('//example.com')).toBe(false);
});
it('should reject relative URLs', () => {
expect(isValidUnsubscribeUrl('/unsubscribe')).toBe(false);
expect(isValidUnsubscribeUrl('../unsub')).toBe(false);
});
});
});
describe('parseUnsubscribeUrls', () => {
it('should parse single HTTP URL', () => {
const result = parseUnsubscribeUrls('<https://example.com/unsub>');
expect(result.http).toBe('https://example.com/unsub');
expect(result.mailto).toBeUndefined();
expect(result.preferred).toBe('http');
});
it('should parse single mailto URL', () => {
const result = parseUnsubscribeUrls('<mailto:unsub@example.com>');
expect(result.http).toBeUndefined();
expect(result.mailto).toBe('mailto:unsub@example.com');
expect(result.preferred).toBe('mailto');
});
it('should parse multiple URLs and prefer HTTP', () => {
const result = parseUnsubscribeUrls('<https://example.com/unsub>, <mailto:unsub@example.com>');
expect(result.http).toBe('https://example.com/unsub');
expect(result.mailto).toBe('mailto:unsub@example.com');
expect(result.preferred).toBe('http');
});
it('should prefer HTTP over mailto when both present', () => {
const result = parseUnsubscribeUrls('<mailto:unsub@example.com>, <https://example.com/unsub>');
expect(result.http).toBe('https://example.com/unsub');
expect(result.mailto).toBe('mailto:unsub@example.com');
expect(result.preferred).toBe('http');
});
it('should handle URLs with query parameters', () => {
const result = parseUnsubscribeUrls('<https://example.com/unsub?token=abc123&id=456>');
expect(result.http).toBe('https://example.com/unsub?token=abc123&id=456');
expect(result.preferred).toBe('http');
});
it('should handle mailto with query parameters', () => {
const result = parseUnsubscribeUrls('<mailto:unsub@example.com?subject=Unsubscribe&body=Remove>');
expect(result.mailto).toBe('mailto:unsub@example.com?subject=Unsubscribe&body=Remove');
expect(result.preferred).toBe('mailto');
});
it('should filter out invalid URLs', () => {
const result = parseUnsubscribeUrls('<javascript:alert(1)>, <https://example.com/unsub>');
expect(result.http).toBe('https://example.com/unsub');
expect(result.preferred).toBe('http');
});
it('should return empty object for all invalid URLs', () => {
const result = parseUnsubscribeUrls('<javascript:alert(1)>, <data:text/html,<script>>');
expect(result.http).toBeUndefined();
expect(result.mailto).toBeUndefined();
expect(result.preferred).toBeUndefined();
});
it('should handle empty or null input', () => {
expect(parseUnsubscribeUrls('')).toEqual({});
expect(parseUnsubscribeUrls(' ')).toEqual({});
});
it('should handle malformed headers without angle brackets', () => {
const result = parseUnsubscribeUrls('https://example.com/unsub');
expect(result).toEqual({});
});
it('should handle whitespace in headers', () => {
const result = parseUnsubscribeUrls(' <https://example.com/unsub> , <mailto:unsub@example.com> ');
expect(result.http).toBe('https://example.com/unsub');
expect(result.mailto).toBe('mailto:unsub@example.com');
expect(result.preferred).toBe('http');
});
it('should handle three or more URLs', () => {
const result = parseUnsubscribeUrls(
'<https://example.com/unsub>, <http://backup.com/unsub>, <mailto:unsub@example.com>'
);
expect(result.http).toBeDefined();
expect(result.mailto).toBe('mailto:unsub@example.com');
expect(result.preferred).toBe('http');
});
it('should validate email addresses in mailto URLs', () => {
const result = parseUnsubscribeUrls('<mailto:invalid-email>, <https://example.com/unsub>');
expect(result.mailto).toBeUndefined();
expect(result.http).toBe('https://example.com/unsub');
expect(result.preferred).toBe('http');
});
});
});
+209
View File
@@ -0,0 +1,209 @@
type RGB = { r: number; g: number; b: number; a?: number };
const namedColors: Record<string, string> = {
transparent: 'rgba(0,0,0,0)',
black: '#000000',
white: '#ffffff',
red: '#ff0000',
green: '#008000',
blue: '#0000ff',
yellow: '#ffff00',
cyan: '#00ffff',
magenta: '#ff00ff',
gray: '#808080',
grey: '#808080',
silver: '#c0c0c0',
maroon: '#800000',
olive: '#808000',
lime: '#00ff00',
aqua: '#00ffff',
teal: '#008080',
navy: '#000080',
fuchsia: '#ff00ff',
purple: '#800080',
};
export function parseColor(colorString: string): RGB | null {
if (!colorString || typeof colorString !== 'string') {
return null;
}
const color = colorString.trim().toLowerCase();
if (color === 'inherit' || color === 'currentcolor') {
return null;
}
if (namedColors[color]) {
return parseColor(namedColors[color]);
}
if (color === 'transparent') {
return { r: 0, g: 0, b: 0, a: 0 };
}
const hexMatch = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (hexMatch) {
const hex = hexMatch[1];
if (hex.length === 3) {
return {
r: parseInt(hex[0] + hex[0], 16),
g: parseInt(hex[1] + hex[1], 16),
b: parseInt(hex[2] + hex[2], 16),
};
}
return {
r: parseInt(hex.substr(0, 2), 16),
g: parseInt(hex.substr(2, 2), 16),
b: parseInt(hex.substr(4, 2), 16),
};
}
const rgbMatch = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)$/);
if (rgbMatch) {
const r = parseInt(rgbMatch[1], 10);
const g = parseInt(rgbMatch[2], 10);
const b = parseInt(rgbMatch[3], 10);
const a = rgbMatch[4] ? parseFloat(rgbMatch[4]) : undefined;
if (r > 255 || g > 255 || b > 255 || r < 0 || g < 0 || b < 0) {
return null;
}
if (a !== undefined && (a < 0 || a > 1)) {
return null;
}
return { r, g, b, a };
}
const hslMatch = color.match(/^hsla?\((\d+),\s*([\d.]+)%,\s*([\d.]+)%(?:,\s*([\d.]+))?\)$/);
if (hslMatch) {
const h = parseInt(hslMatch[1], 10) / 360;
const s = parseFloat(hslMatch[2]) / 100;
const l = parseFloat(hslMatch[3]) / 100;
const a = hslMatch[4] ? parseFloat(hslMatch[4]) : undefined;
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return {
r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
g: Math.round(hue2rgb(p, q, h) * 255),
b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
a,
};
}
return null;
}
export function getLuminance(r: number, g: number, b: number): number {
const [rs, gs, bs] = [r, g, b].map((c) => {
const val = c / 255;
return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
export function isDarkColor(colorString: string): boolean {
const rgb = parseColor(colorString);
if (!rgb) return false;
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
return luminance < 0.5;
}
export function transformColorForDarkMode(colorString: string): string {
const rgb = parseColor(colorString);
if (!rgb) return colorString;
if (rgb.a !== undefined && rgb.a < 0.1) {
return colorString;
}
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
if (luminance < 0.4) {
const invR = 255 - rgb.r;
const invG = 255 - rgb.g;
const invB = 255 - rgb.b;
const boost = 1.3;
const r = Math.min(255, Math.round(invR * boost));
const g = Math.min(255, Math.round(invG * boost));
const b = Math.min(255, Math.round(invB * boost));
return rgb.a !== undefined ? `rgba(${r}, ${g}, ${b}, ${rgb.a})` : `rgb(${r}, ${g}, ${b})`;
}
if (luminance >= 0.4 && luminance < 0.6) {
const factor = 1.5;
const r = Math.min(255, Math.round(rgb.r + (255 - rgb.r) * factor * 0.4));
const g = Math.min(255, Math.round(rgb.g + (255 - rgb.g) * factor * 0.4));
const b = Math.min(255, Math.round(rgb.b + (255 - rgb.b) * factor * 0.4));
return rgb.a !== undefined ? `rgba(${r}, ${g}, ${b}, ${rgb.a})` : `rgb(${r}, ${g}, ${b})`;
}
return colorString;
}
export function transformInlineStyles(cssText: string, theme: 'light' | 'dark'): string {
if (theme !== 'dark' || !cssText) {
return cssText;
}
const styleProps = cssText.split(';').map((prop) => prop.trim()).filter(Boolean);
const transformedProps = styleProps.map((prop) => {
const colonIndex = prop.indexOf(':');
if (colonIndex === -1) return prop;
const property = prop.slice(0, colonIndex).trim();
const value = prop.slice(colonIndex + 1).trim();
if (property === 'color') {
const hasImportant = value.includes('!important');
const colorValue = value.replace('!important', '').trim();
const transformed = transformColorForDarkMode(colorValue);
return `${property}: ${transformed}${hasImportant ? ' !important' : ''}`;
}
if (property === 'background-color') {
const hasImportant = value.includes('!important');
const colorValue = value.replace('!important', '').trim();
const transformed = transformColorForDarkMode(colorValue);
return `${property}: ${transformed}${hasImportant ? ' !important' : ''}`;
}
if (property === 'background' && !value.includes('url(')) {
const colorMatch = value.match(/#[0-9a-f]{3,6}|rgba?\([^)]+\)|hsla?\([^)]+\)|[a-z]+/i);
if (colorMatch) {
const hasImportant = value.includes('!important');
const originalColor = colorMatch[0];
const transformed = transformColorForDarkMode(originalColor);
const newValue = value.replace(originalColor, transformed);
return `${property}: ${newValue.replace('!important', '').trim()}${hasImportant ? ' !important' : ''}`;
}
}
if (property === 'border-color') {
const hasImportant = value.includes('!important');
const colorValue = value.replace('!important', '').trim();
const transformed = transformColorForDarkMode(colorValue);
return `${property}: ${transformed}${hasImportant ? ' !important' : ''}`;
}
return prop;
});
return transformedProps.join('; ');
}
+11 -4
View File
@@ -1,4 +1,5 @@
import { AuthenticationResults } from './jmap/types'; import { AuthenticationResults } from './jmap/types';
import { parseUnsubscribeUrls } from './validation';
/** /**
* Parse Authentication-Results header to extract SPF, DKIM, DMARC results * Parse Authentication-Results header to extract SPF, DKIM, DMARC results
@@ -191,7 +192,11 @@ export function parseSpamLLM(header: string): { verdict: string; explanation: st
*/ */
interface ListHeaders { interface ListHeaders {
listId?: string; listId?: string;
listUnsubscribe?: string; listUnsubscribe?: {
http?: string;
mailto?: string;
preferred?: 'http' | 'mailto';
};
listHelp?: string; listHelp?: string;
listPost?: string; listPost?: string;
} }
@@ -209,9 +214,11 @@ export function extractListHeaders(headers: Record<string, string | string[]>):
const unsub = Array.isArray(headers['List-Unsubscribe']) const unsub = Array.isArray(headers['List-Unsubscribe'])
? headers['List-Unsubscribe'][0] ? headers['List-Unsubscribe'][0]
: headers['List-Unsubscribe']; : headers['List-Unsubscribe'];
// Extract URL from <url> format
const match = unsub.match(/<([^>]+)>/); const parsed = parseUnsubscribeUrls(unsub);
result.listUnsubscribe = match ? match[1] : unsub; if (parsed.preferred) {
result.listUnsubscribe = parsed;
}
} }
if (headers['List-Help']) { if (headers['List-Help']) {
+77
View File
@@ -0,0 +1,77 @@
import DOMPurify from 'dompurify';
/**
* Unified DOMPurify configuration for email content
* Blocks all script execution vectors while preserving formatting
* NOTE: <style> tags are forbidden to prevent global CSS injection
* Inline style attributes are still allowed for element-specific styling
*/
export const EMAIL_SANITIZE_CONFIG = {
ADD_TAGS: [],
ADD_ATTR: ['target', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
ALLOW_DATA_ATTR: false,
FORCE_BODY: true,
FORBID_TAGS: [
'script', 'iframe', 'object', 'embed', 'form',
'input', 'button', 'meta', 'link', 'base',
'svg', 'math', 'style'
],
FORBID_ATTR: [
'onerror', 'onload', 'onclick', 'onmouseover',
'onfocus', 'onblur', 'onchange', 'onsubmit',
'onkeydown', 'onkeyup', 'onmousedown', 'onmouseup'
],
};
/**
* Sanitize email HTML content
* @param html - Raw HTML content from email
* @returns Sanitized HTML safe for rendering
*/
export function sanitizeEmailHtml(html: string): string {
return DOMPurify.sanitize(html, EMAIL_SANITIZE_CONFIG);
}
/**
* Sanitize HTML signature with stricter rules
* Only allows basic formatting, no external resources
*/
export const SIGNATURE_SANITIZE_CONFIG = {
ALLOWED_TAGS: ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'span', 'div'],
ALLOWED_ATTR: ['href', 'style', 'class'],
ALLOW_DATA_ATTR: false,
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'img', 'video', 'audio'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'],
};
/**
* Sanitize HTML signature for storage and display
* @param html - User-provided HTML signature
* @returns Sanitized signature (no scripts, no external resources)
*/
export function sanitizeSignatureHtml(html: string): string {
if (!html?.trim()) return '';
return DOMPurify.sanitize(html, SIGNATURE_SANITIZE_CONFIG);
}
/**
* Safe HTML parsing without execution
* Use instead of innerHTML for detection/parsing
*/
export function parseHtmlSafely(html: string): Document {
const parser = new DOMParser();
return parser.parseFromString(html, 'text/html');
}
/**
* Detect if HTML content has rich formatting
* Safe alternative to innerHTML parsing
*/
export function hasRichFormatting(html: string): boolean {
const doc = parseHtmlSafely(html);
return !!doc.querySelector(
'table, img, style, b, strong, i, em, u, font, ' +
'div[style], span[style], p[style], ' +
'h1, h2, h3, h4, h5, h6, ul, ol, blockquote'
);
}
+182 -14
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity } from "./types"; import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress } from "./types";
// JMAP protocol types - these are intentionally flexible due to server variations // JMAP protocol types - these are intentionally flexible due to server variations
interface JMAPSession { interface JMAPSession {
@@ -753,6 +753,58 @@ export class JMAPClient {
]); ]);
} }
/**
* Move email to Junk folder
*/
async markAsSpam(emailId: string, accountId?: string): Promise<void> {
const targetAccountId = accountId || this.accountId;
const mailboxes = await this.getMailboxes();
const junkMailbox = mailboxes.find(m => {
if (accountId) {
return m.role === 'junk' && m.accountId === accountId;
}
return m.role === 'junk' && !m.isShared;
});
if (!junkMailbox) {
throw new Error('Junk mailbox not found');
}
const mailboxId = accountId && junkMailbox.originalId
? junkMailbox.originalId
: junkMailbox.id;
await this.request([
["Email/set", {
accountId: targetAccountId,
update: {
[emailId]: {
mailboxIds: { [mailboxId]: true },
},
},
}, "0"],
]);
}
/**
* Undo spam - move email back from Junk to original mailbox
*/
async undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void> {
const targetAccountId = accountId || this.accountId;
await this.request([
["Email/set", {
accountId: targetAccountId,
update: {
[emailId]: {
mailboxIds: { [originalMailboxId]: true },
},
},
}, "0"],
]);
}
async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> { async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
try { try {
// Use provided accountId or fallback to primary account // Use provided accountId or fallback to primary account
@@ -920,15 +972,134 @@ export class JMAPClient {
} }
} }
async createIdentity(
name: string,
email: string,
replyTo?: EmailAddress[],
bcc?: EmailAddress[],
textSignature?: string,
htmlSignature?: string
): Promise<Identity> {
const response = await this.request([
["Identity/set", {
accountId: this.accountId,
create: {
"new-identity": {
name,
email,
replyTo,
bcc,
textSignature,
htmlSignature,
}
}
}, "0"]
]);
if (response.methodResponses?.[0]?.[0] === "Identity/set") {
const result = response.methodResponses[0][1];
// Check for errors
if (result.notCreated?.["new-identity"]) {
const error = result.notCreated["new-identity"];
if (error.type === "forbidden") {
throw new Error("You are not authorized to send from this email address");
}
throw new Error(error.description || "Failed to create identity");
}
// Return created identity
const createdId = result.created?.["new-identity"]?.id;
if (createdId) {
// Fetch the full identity object
const identities = await this.getIdentities();
const identity = identities.find(i => i.id === createdId);
if (identity) return identity;
}
}
throw new Error("Failed to create identity: Server response was unexpected. Check server logs.");
}
async updateIdentity(
identityId: string,
updates: {
name?: string;
replyTo?: EmailAddress[];
bcc?: EmailAddress[];
textSignature?: string;
htmlSignature?: string;
}
): Promise<void> {
const response = await this.request([
["Identity/set", {
accountId: this.accountId,
update: {
[identityId]: updates
}
}, "0"]
]);
if (response.methodResponses?.[0]?.[0] === "Identity/set") {
const result = response.methodResponses[0][1];
// Check for errors
if (result.notUpdated?.[identityId]) {
const error = result.notUpdated[identityId];
if (error.type === "notFound") {
throw new Error("Identity not found (may have been deleted)");
}
if (error.type === "forbidden") {
throw new Error("You are not authorized to modify this identity");
}
throw new Error(error.description || "Failed to update identity");
}
return;
}
throw new Error("Failed to update identity: Server response was unexpected. Check server logs.");
}
async deleteIdentity(identityId: string): Promise<void> {
const response = await this.request([
["Identity/set", {
accountId: this.accountId,
destroy: [identityId]
}, "0"]
]);
if (response.methodResponses?.[0]?.[0] === "Identity/set") {
const result = response.methodResponses[0][1];
// Check for errors
if (result.notDestroyed?.[identityId]) {
const error = result.notDestroyed[identityId];
if (error.type === "forbidden") {
throw new Error("This identity cannot be deleted");
}
if (error.type === "notFound") {
throw new Error("Identity not found (may already be deleted)");
}
throw new Error(error.description || "Failed to delete identity");
}
return;
}
throw new Error("Failed to delete identity: Server response was unexpected. Check server logs.");
}
async createDraft( async createDraft(
to: string[], to: string[],
subject: string, subject: string,
body: string, body: string,
cc?: string[], cc?: string[],
bcc?: string[], bcc?: string[],
identityId?: string,
fromEmail?: string,
draftId?: string, draftId?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>, attachments?: Array<{ blobId: string; name: string; type: string; size: number }>
fromEmail?: string
): Promise<string> { ): Promise<string> {
// Find the drafts mailbox // Find the drafts mailbox
const mailboxes = await this.getMailboxes(); const mailboxes = await this.getMailboxes();
@@ -1013,8 +1184,6 @@ export class JMAPClient {
const response = await this.request(methodCalls); const response = await this.request(methodCalls);
console.log('Draft save response:', JSON.stringify(response, null, 2));
// If we're updating (destroy + create), check the second response // If we're updating (destroy + create), check the second response
// Otherwise check the first response // Otherwise check the first response
const responseIndex = draftId ? 1 : 0; const responseIndex = draftId ? 1 : 0;
@@ -1031,7 +1200,6 @@ export class JMAPClient {
} }
if (result.created?.[emailId]) { if (result.created?.[emailId]) {
console.log('Draft created successfully:', result.created[emailId].id);
return result.created[emailId].id; return result.created[emailId].id;
} }
} }
@@ -1046,9 +1214,9 @@ export class JMAPClient {
body: string, body: string,
cc?: string[], cc?: string[],
bcc?: string[], bcc?: string[],
draftId?: string, identityId?: string,
fromEmail?: string, fromEmail?: string,
selectedIdentityId?: string draftId?: string
): Promise<void> { ): Promise<void> {
const emailId = draftId || `draft-${Date.now()}`; const emailId = draftId || `draft-${Date.now()}`;
@@ -1061,16 +1229,16 @@ export class JMAPClient {
} }
// Use provided identity ID or fetch from server as fallback // Use provided identity ID or fetch from server as fallback
let identityId = selectedIdentityId; let finalIdentityId = identityId;
if (!identityId) { if (!finalIdentityId) {
const identityResponse = await this.request([ const identityResponse = await this.request([
["Identity/get", { ["Identity/get", {
accountId: this.accountId, accountId: this.accountId,
}, "0"] }, "0"]
]); ]);
identityId = this.accountId; // fallback finalIdentityId = this.accountId; // fallback
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") { if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[]; const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
@@ -1078,7 +1246,7 @@ export class JMAPClient {
if (identities.length > 0) { if (identities.length > 0) {
// Use the first identity (or find one matching the fromEmail/username) // Use the first identity (or find one matching the fromEmail/username)
const matchingIdentity = identities.find((id) => id.email === (fromEmail || this.username)); const matchingIdentity = identities.find((id) => id.email === (fromEmail || this.username));
identityId = matchingIdentity?.id || identities[0].id; finalIdentityId = matchingIdentity?.id || identities[0].id;
} }
} }
} }
@@ -1103,7 +1271,7 @@ export class JMAPClient {
create: { create: {
"1": { "1": {
emailId: draftId, emailId: draftId,
identityId: identityId, identityId: finalIdentityId,
}, },
}, },
}, "1"]); }, "1"]);
@@ -1137,7 +1305,7 @@ export class JMAPClient {
create: { create: {
"1": { "1": {
emailId: `#${emailId}`, emailId: `#${emailId}`,
identityId: identityId, identityId: finalIdentityId,
}, },
}, },
}, "1"]); }, "1"]);
+167
View File
@@ -0,0 +1,167 @@
/**
* Sub-addressing utilities for user+tag@domain.com format
* Works server-side automatically - no JMAP API calls needed
*/
// Constants for tag validation
const MAX_TAG_LENGTH = 30;
const TAG_REGEX = /^[a-zA-Z0-9-]{1,30}$/;
export type TagValidationErrorCode =
| 'EMPTY'
| 'TOO_LONG'
| 'INVALID_CHARS'
| null;
export interface ParsedAddress {
localPart: string;
baseUser: string;
tag: string | null;
domain: string;
fullAddress: string;
}
/**
* Parse an email address to extract sub-address tag
* Example: "user+shopping@example.com" -> { baseUser: "user", tag: "shopping" }
*/
export function parseSubAddress(email: string): ParsedAddress {
const [localPart, domain] = email.split('@');
if (!localPart || !domain) {
return {
localPart: localPart || '',
baseUser: localPart || '',
tag: null,
domain: domain || '',
fullAddress: email,
};
}
const plusIndex = localPart.indexOf('+');
if (plusIndex === -1) {
return {
localPart,
baseUser: localPart,
tag: null,
domain,
fullAddress: email,
};
}
const baseUser = localPart.substring(0, plusIndex);
const tag = localPart.substring(plusIndex + 1);
return {
localPart,
baseUser,
tag: tag || null,
domain,
fullAddress: email,
};
}
/**
* Generate a sub-addressed email
* Example: generateSubAddress("user@example.com", "shopping") -> "user+shopping@example.com"
*/
export function generateSubAddress(baseEmail: string, tag: string): string {
const [localPart, domain] = baseEmail.split('@');
if (!localPart || !domain || !tag) {
return baseEmail;
}
// Remove existing tag if present
const cleanLocal = localPart.split('+')[0];
// Sanitize tag (alphanumeric and dash only)
const cleanTag = tag.replace(/[^a-zA-Z0-9-]/g, '').toLowerCase();
if (!cleanTag) {
return baseEmail;
}
return `${cleanLocal}+${cleanTag}@${domain}`;
}
/**
* Extract domain from recipient email for tag suggestions
*/
export function extractDomain(email: string): string | null {
const match = email.match(/@([^@]+)$/);
return match ? match[1].toLowerCase() : null;
}
/**
* Suggest tags based on recipient domain
*/
export function suggestTagsForDomain(domain: string): string[] {
const domainLower = domain.toLowerCase();
// Common domain-based suggestions
const suggestions: Record<string, string[]> = {
'amazon.com': ['amazon', 'shopping', 'orders'],
'amazon.fr': ['amazon', 'shopping', 'orders'],
'amazon.de': ['amazon', 'shopping', 'orders'],
'amazon.co.uk': ['amazon', 'shopping', 'orders'],
'ebay.com': ['ebay', 'shopping'],
'ebay.fr': ['ebay', 'shopping'],
'paypal.com': ['paypal', 'payments'],
'facebook.com': ['facebook', 'social'],
'twitter.com': ['twitter', 'social'],
'x.com': ['twitter', 'social'],
'linkedin.com': ['linkedin', 'professional'],
'github.com': ['github', 'dev', 'notifications'],
'gitlab.com': ['gitlab', 'dev', 'notifications'],
'stackoverflow.com': ['stackoverflow', 'dev'],
'reddit.com': ['reddit', 'social'],
'netflix.com': ['netflix', 'entertainment'],
'spotify.com': ['spotify', 'music'],
'steam.com': ['steam', 'gaming'],
'discord.com': ['discord', 'gaming'],
};
// Check for exact domain match
if (suggestions[domainLower]) {
return suggestions[domainLower];
}
// Extract main domain (e.g., "mail.google.com" -> "google")
const parts = domainLower.split('.');
const mainDomain = parts.length >= 2 ? parts[parts.length - 2] : parts[0];
// Generic suggestions based on domain name
return [mainDomain, 'newsletter', 'registration'];
}
/**
* Validate if a tag is safe to use
*/
export function isValidTag(tag: string): boolean {
return TAG_REGEX.test(tag);
}
/**
* Get validation error code for an invalid tag
* Returns an error code that should be translated by the calling component
*/
export function getTagValidationError(tag: string): TagValidationErrorCode {
if (!tag) {
return 'EMPTY';
}
if (tag.length > MAX_TAG_LENGTH) {
return 'TOO_LONG';
}
if (!/^[a-zA-Z0-9-]+$/.test(tag)) {
return 'INVALID_CHARS';
}
return null;
}
// Export MAX_TAG_LENGTH for use in translations
export { MAX_TAG_LENGTH };
+123
View File
@@ -0,0 +1,123 @@
/**
* RFC 5322 compliant email validation with security enhancements
*/
export function isValidEmail(email: string): boolean {
// Length check
if (!email || email.length > 254) return false;
// Security: Block control characters and header injection
if (/[\r\n\0<>]/.test(email)) return false;
// RFC 5322 compliant regex (simplified but secure)
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
if (!emailRegex.test(email)) return false;
// Additional checks
const [localPart, domain] = email.split('@');
// Local part max 64 chars
if (localPart.length > 64) return false;
// Domain validation
if (domain.length > 255) return false;
if (domain.startsWith('.') || domain.endsWith('.')) return false;
if (domain.includes('..')) return false;
return true;
}
/**
* Validate comma-separated email list
* @returns Object with validation result and invalid emails
*/
export function validateEmailList(csv: string): {
valid: boolean;
invalidEmails: string[];
} {
if (!csv?.trim()) {
return { valid: true, invalidEmails: [] };
}
const emails = csv.split(',').map(e => e.trim()).filter(Boolean);
const invalid = emails.filter(e => !isValidEmail(e));
return {
valid: invalid.length === 0,
invalidEmails: invalid
};
}
/**
* Get user-friendly validation error message
*/
export function getEmailValidationError(email: string): string | null {
if (!email?.trim()) return 'Email address is required';
if (email.length > 254) return 'Email address is too long (max 254 characters)';
if (/[\r\n\0<>]/.test(email)) {
return 'Email address contains invalid characters';
}
if (!isValidEmail(email)) {
return 'Please enter a valid email address';
}
return null;
}
/**
* Validate unsubscribe URL (RFC 2369 List-Unsubscribe)
* Only allows safe protocols: http, https, mailto
* @param url - URL to validate
* @returns true if URL is safe to use
*/
export function isValidUnsubscribeUrl(url: string): boolean {
if (!url?.trim()) return false;
if (url.startsWith('mailto:')) {
const email = url.substring(7);
const emailPart = email.split('?')[0];
return isValidEmail(emailPart);
}
try {
const parsed = new URL(url);
return ['http:', 'https:'].includes(parsed.protocol);
} catch {
return false;
}
}
/**
* Parse List-Unsubscribe header and extract all valid URLs
* RFC 2369 allows multiple comma-separated URLs in <url> format
* @param header - Raw List-Unsubscribe header value
* @returns Object with http and mailto URLs, plus preferred method
*/
export function parseUnsubscribeUrls(header: string): {
http?: string;
mailto?: string;
preferred?: 'http' | 'mailto';
} {
if (!header?.trim()) return {};
const matches = header.match(/<([^>]+)>/g);
if (!matches) return {};
const urls = matches.map(m => m.slice(1, -1).trim());
const http = urls.find(u =>
(u.startsWith('http://') || u.startsWith('https://')) &&
isValidUnsubscribeUrl(u)
);
const mailto = urls.find(u =>
u.startsWith('mailto:') &&
isValidUnsubscribeUrl(u)
);
const preferred = http ? 'http' : (mailto ? 'mailto' : undefined);
return { http, mailto, preferred };
}
+746
View File
@@ -0,0 +1,746 @@
{
"login": {
"title": "Webmail",
"username_label": "E-Mail",
"username_placeholder": "benutzer@beispiel.de",
"password_label": "Passwort",
"password_placeholder": "Geben Sie Ihr Passwort ein",
"sign_in": "Anmelden",
"signing_in": "Wird angemeldet...",
"loading": "Lädt...",
"error": {
"invalid_credentials": "Ungültige E-Mail-Adresse oder Passwort",
"connection_failed": "Verbindung zum Server fehlgeschlagen",
"generic": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut."
},
"config_error": {
"title": "Konfigurationsfehler",
"fetch_failed": "Die Anwendungskonfiguration kann nicht geladen werden. Bitte versuchen Sie es später erneut.",
"server_not_configured": "Der E-Mail-Server wurde nicht konfiguriert. Bitte kontaktieren Sie Ihren Administrator."
},
"remove_from_history": "Aus Verlauf entfernen"
},
"sidebar": {
"close": "Schließen",
"compose": "Verfassen",
"search_placeholder": "E-Mails durchsuchen...",
"storage": "Speicher",
"sign_out": "Abmelden",
"settings": "Einstellungen",
"loading_mailboxes": "Postfächer werden geladen...",
"push_connected": "Echtzeit-Updates aktiv",
"push_disconnected": "Echtzeit-Updates inaktiv",
"theme": {
"light": "Heller Modus",
"dark": "Dunkler Modus",
"system": "Systemdesign"
},
"language": {
"title": "Sprache"
},
"mailboxes": {
"inbox": "Posteingang",
"sent": "Gesendet",
"drafts": "Entwürfe",
"trash": "Papierkorb",
"archive": "Archiv",
"starred": "Mit Stern",
"all_mail": "Alle E-Mails",
"spam": "Spam",
"important": "Wichtig"
},
"expand": "Erweitern",
"collapse": "Einklappen",
"expand_tooltip": "Erweitern",
"collapse_tooltip": "Einklappen",
"mobile": {
"search": "Suchen",
"compose": "Verfassen",
"go_back": "Zurück"
},
"clear_search": "Suche löschen"
},
"email_list": {
"no_emails": "Keine E-Mails",
"no_emails_description": "Beginnen Sie, indem Sie eine neue E-Mail verfassen",
"loading": "E-Mails werden geladen...",
"unread": "ungelesen",
"to_me": "An mich",
"to_recipients": "An {{count}} Empfänger",
"and_others": "und {{count}} weitere",
"draft": "Entwurf",
"starred": "Mit Stern",
"conversations_count": "{count} von {total} Unterhaltungen",
"conversations_count_plus": "{count}+ Unterhaltungen",
"conversations_count_simple": "{count} Unterhaltungen",
"no_conversations": "Keine Unterhaltungen",
"loading_more": "Weitere E-Mails werden geladen...",
"no_more_emails": "Keine weiteren E-Mails zum Laden",
"batch_actions": {
"mark_read": "Als gelesen markieren",
"mark_unread": "Als ungelesen markieren",
"delete": "Löschen",
"clear_selection": "Auswahl aufheben"
}
},
"email_viewer": {
"no_email_selected": "Keine E-Mail ausgewählt",
"no_email_description": "Wählen Sie eine E-Mail aus der Liste aus, um sie hier anzuzeigen",
"no_conversation_selected": "Keine Unterhaltung ausgewählt",
"no_conversation_description": "Wählen Sie eine Unterhaltung aus der Liste aus, um sie hier zu lesen",
"no_subject": "(Kein Betreff)",
"loading_email": "E-Mail wird geladen...",
"loading": "Lädt...",
"reply": "Antworten",
"reply_all": "Allen antworten",
"forward": "Weiterleiten",
"delete": "Löschen",
"archive": "Archivieren",
"star": "Stern hinzufügen",
"unstar": "Stern entfernen",
"mark_unread": "Als ungelesen markieren",
"mark_read": "Als gelesen markieren",
"print": "Drucken",
"view_source": "Quelltext anzeigen",
"email_source": "E-Mail-Quelltext",
"copy_source": "In Zwischenablage kopieren",
"source_copied": "Quelltext in Zwischenablage kopiert",
"attachments": "Anhänge",
"important": "Wichtig",
"download": "Herunterladen",
"from": "Von",
"to": "An",
"cc": "CC",
"bcc": "BCC",
"date": "Datum",
"subject": "Betreff",
"show_details": "Details anzeigen",
"hide_details": "Details ausblenden",
"external_content_warning": "Bilder und externe Inhalte wurden blockiert",
"load_external_content": "Bilder laden",
"trust_sender": "Diesem Absender immer vertrauen",
"back_to_list": "Zurück zur Liste",
"message_details": "Nachrichtendetails",
"more_reply_options": "Weitere Antwortoptionen",
"set_color": "Farbe festlegen",
"more_actions": "Weitere Aktionen",
"remove_color": "Farbe entfernen",
"more_count": "+{count} weitere",
"characters_count": "{count} Zeichen",
"quick_reply_placeholder": "Eine kurze Antwort schreiben...",
"more_options": "Weitere Optionen",
"sending": "Wird gesendet...",
"security_authentication": "Sicherheit & Authentifizierung",
"technical_details": "Technische Details",
"message_id_label": "Nachrichten-ID:",
"reply_to_label": "Antwort an:",
"delivery_time_label": "Zustellzeit:",
"conversation_part_label": "Teil der Unterhaltung:",
"previous_messages": "{count} vorherige Nachricht",
"previous_messages_plural": "{count} vorherige Nachrichten",
"time": {
"day": "Tag",
"days": "Tage",
"hour": "Stunde",
"hours": "Stunden",
"minute": "Minute",
"minutes": "Minuten"
},
"unknown_sender": "Unbekannt",
"recipient_me": "ich",
"recipient_and_others": "{name} und {count} weitere",
"recipient_to_prefix": "An:",
"authentication": {
"title": "Authentifizierung",
"status": {
"verified": "Verifiziert",
"warning": "Warnung",
"none": "Nicht authentifiziert"
},
"spf": {
"pass": "SPF Bestanden",
"fail": "SPF Fehlgeschlagen",
"none": "Kein SPF"
},
"dkim": {
"pass": "DKIM Gültig",
"fail": "DKIM Ungültig",
"none": "Kein DKIM"
},
"dmarc": {
"pass": "DMARC Bestanden",
"fail": "DMARC Fehlgeschlagen",
"none": "Kein DMARC"
},
"spam_score": "Spam-Bewertung"
},
"headers": {
"routing": "Routing",
"received": "Empfangen",
"message_id": "Nachrichten-ID",
"list_info": "Listeninformationen"
},
"color_tag": {
"title": "Farb-Tag",
"red": "Rot",
"orange": "Orange",
"yellow": "Gelb",
"green": "Grün",
"blue": "Blau",
"purple": "Violett",
"pink": "Rosa",
"none": "Keine"
},
"tooltips": {
"reply": "Antworten",
"archive": "Archivieren",
"delete": "Löschen"
},
"spam": {
"button_title": "Spam melden",
"not_spam_title": "Als legitim markieren",
"toast_success": "In Spam verschoben",
"toast_batch": "{count} E-Mails in Spam verschoben",
"toast_undo": "Rückgängig",
"toast_not_spam_success": "In Posteingang verschoben",
"toast_not_spam_batch": "{count} E-Mails in Posteingang verschoben",
"error": "Spam-Meldung fehlgeschlagen",
"error_not_spam": "E-Mail-Wiederherstellung fehlgeschlagen"
},
"unsubscribe_banner": {
"label": "Newsletter",
"button": "Abmelden",
"confirm_title": "Von diesem Absender abmelden?",
"confirm_button": "Bestätigen",
"cancel": "Abbrechen",
"success_http": "Abmeldeseite in neuem Tab geöffnet",
"success_mailto": "Abmeldeanfrage an Ihr E-Mail-Programm gesendet",
"error": "Abmeldung nicht möglich",
"dismiss": "Schließen"
}
},
"email_composer": {
"new_message": "Neue Nachricht",
"reply": "Antworten",
"reply_all": "Allen antworten",
"forward": "Weiterleiten",
"reply_to": "Antworten",
"reply_all_to": "Allen antworten",
"forward_message": "Weiterleiten",
"from": "Von",
"to": "An",
"cc": "CC",
"bcc": "BCC",
"subject": "Betreff",
"body_placeholder": "Schreiben Sie Ihre Nachricht...",
"send": "Senden",
"cancel": "Abbrechen",
"attach": "Anhängen",
"discard": "Verwerfen",
"discard_draft_confirm": "Sie haben ungespeicherte Änderungen. Möchten Sie diesen Entwurf verwerfen?",
"saving": "Wird gespeichert...",
"draft_saved": "Entwurf gespeichert",
"save_failed": "Speichern fehlgeschlagen",
"to_placeholder": "E-Mail-Adressen der Empfänger (durch Komma getrennt)",
"cc_placeholder": "CC-Empfänger (durch Komma getrennt)",
"bcc_placeholder": "BCC-Empfänger (durch Komma getrennt)",
"subject_placeholder": "Betreff",
"cc_label": "Cc:",
"bcc_label": "Bcc:",
"subject_label": "Betreff:",
"file_size_kb": "KB",
"prefix": {
"forward": "Fwd:",
"reply": "Re:"
},
"no_subject": "(Kein Betreff)",
"unknown_sender": "Unbekannt",
"quote": {
"reply_header": "Am {{date}} schrieb {{sender}}:",
"forward_header": "---------- Weitergeleitete Nachricht ----------",
"from": "Von: {{sender}}",
"date": "Datum: {{date}}",
"subject": "Betreff: {{subject}}",
"to": "An: {{recipients}}"
},
"remove_sub_address": "Sub-Adresse entfernen"
},
"common": {
"loading": "Lädt...",
"error": "Fehler",
"success": "Erfolg",
"cancel": "Abbrechen",
"save": "Speichern",
"delete": "Löschen",
"edit": "Bearbeiten",
"close": "Schließen",
"search": "Suchen",
"refresh": "Aktualisieren",
"settings": "Einstellungen",
"help": "Hilfe",
"logout": "Abmelden",
"yes": "Ja",
"no": "Nein",
"unknown": "Unbekannt",
"app_title": "Webmail"
},
"notifications": {
"email_sent": "E-Mail erfolgreich gesendet",
"email_deleted": "E-Mail gelöscht",
"email_archived": "E-Mail archiviert",
"email_starred": "Stern hinzugefügt",
"email_unstarred": "Stern entfernt",
"email_marked_read": "E-Mail als gelesen markiert",
"email_marked_unread": "E-Mail als ungelesen markiert",
"copied_to_clipboard": "In Zwischenablage kopiert",
"source_copied": "Quelltext in Zwischenablage kopiert",
"error_sending": "E-Mail senden fehlgeschlagen",
"error_deleting": "E-Mail löschen fehlgeschlagen",
"error_loading": "E-Mails laden fehlgeschlagen",
"new_email": "Neue E-Mail",
"new_email_from": "Von {sender}",
"click_to_view": "Zum Anzeigen klicken",
"email_moved": "E-Mail verschoben",
"emails_moved": "{count} E-Mails verschoben",
"moved_to_mailbox": "Nach {mailbox} verschoben",
"move_failed": "Verschieben fehlgeschlagen",
"move_error": "E-Mails konnten nicht in den ausgewählten Ordner verschoben werden",
"identity_created": "Identität erfolgreich erstellt",
"identity_updated": "Identität erfolgreich aktualisiert",
"identity_deleted": "Identität gelöscht",
"identity_create_failed": "Identität erstellen fehlgeschlagen: {{error}}",
"identity_update_failed": "Identität aktualisieren fehlgeschlagen: {{error}}",
"identity_delete_failed": "Identität löschen fehlgeschlagen: {{error}}",
"identity_unauthorized": "Sie sind nicht autorisiert, von dieser E-Mail-Adresse zu senden",
"identity_not_found": "Identität nicht gefunden"
},
"date": {
"today": "Heute",
"yesterday": "Gestern",
"this_week": "Diese Woche",
"last_week": "Letzte Woche",
"this_month": "Dieser Monat",
"older": "Älter",
"just_now": "Gerade eben",
"minutes_ago": "Vor {{count}} Minute",
"minutes_ago_plural": "Vor {{count}} Minuten",
"hours_ago": "Vor {{count}} Stunde",
"hours_ago_plural": "Vor {{count}} Stunden",
"days_ago": "Vor {{count}} Tag",
"days_ago_plural": "Vor {{count}} Tagen"
},
"language": {
"title": "Sprache",
"english": "English",
"french": "Français",
"japanese": "日本語",
"spanish": "Español",
"italian": "Italiano",
"german": "Deutsch",
"dutch": "Nederlands",
"portuguese": "Português",
"select_language": "Sprache auswählen",
"switch_to_english": "Zu Englisch wechseln",
"switch_to_french": "Zu Französisch wechseln",
"switch_to_japanese": "Zu Japanisch wechseln",
"switch_to_spanish": "Zu Spanisch wechseln",
"switch_to_italian": "Zu Italienisch wechseln",
"switch_to_german": "Zu Deutsch wechseln",
"switch_to_dutch": "Zu Niederländisch wechseln",
"switch_to_portuguese": "Zu Portugiesisch wechseln",
"switching": "Sprache wird gewechselt..."
},
"settings": {
"title": "Einstellungen",
"back_to_mail": "Zurück zu E-Mails",
"save_success": "Einstellungen erfolgreich gespeichert",
"import_success": "Einstellungen erfolgreich importiert",
"import_error": "Import der Einstellungen fehlgeschlagen",
"reset_confirm": "Sind Sie sicher, dass Sie alle Einstellungen auf die Standardwerte zurücksetzen möchten?",
"tabs": {
"appearance": "Darstellung",
"language": "Sprache & Region",
"email": "E-Mail-Verhalten",
"composer": "Editor",
"privacy": "Datenschutz & Sicherheit",
"account": "Konto",
"identities": "Identitäten",
"advanced": "Erweitert"
},
"appearance": {
"title": "Darstellung",
"description": "Passen Sie das Aussehen Ihres Webmails an",
"theme": {
"label": "Design",
"description": "Wählen Sie Ihr bevorzugtes Farbschema",
"light": "Hell",
"dark": "Dunkel",
"system": "System"
},
"language": {
"label": "Sprache",
"description": "Wählen Sie Ihre bevorzugte Sprache"
},
"font_size": {
"label": "Schriftgröße",
"description": "Passen Sie die Textgröße für bessere Lesbarkeit an",
"small": "Klein",
"medium": "Mittel",
"large": "Groß"
},
"list_density": {
"label": "Listendichte",
"description": "Abstand in E-Mail-Listen steuern",
"compact": "Kompakt",
"regular": "Normal",
"comfortable": "Komfortabel"
},
"animations": {
"label": "Animationen aktivieren",
"description": "Weiche Übergänge und Effekte anzeigen"
}
},
"language_region": {
"title": "Sprache & Region",
"description": "Konfigurieren Sie Sprach- und Regionaleinstellungen",
"language": {
"label": "Sprache",
"description": "Wählen Sie Ihre bevorzugte Sprache",
"english": "English",
"french": "Français"
},
"date_format": {
"label": "Datumsformat",
"description": "Wie Daten angezeigt werden sollen",
"regional": "Regional",
"iso": "ISO 8601",
"custom": "Benutzerdefiniert"
},
"time_format": {
"label": "Zeitformat",
"description": "Wählen Sie zwischen 12-Stunden- oder 24-Stunden-Anzeige",
"12h": "12-Stunden",
"24h": "24-Stunden"
},
"first_day": {
"label": "Erster Tag der Woche",
"description": "Woche am Sonntag oder Montag beginnen",
"sunday": "Sonntag",
"monday": "Montag"
}
},
"email_behavior": {
"title": "E-Mail-Verhalten",
"description": "Konfigurieren Sie, wie E-Mails verarbeitet werden",
"mark_read": {
"label": "Als gelesen markieren",
"description": "Wann E-Mails beim Öffnen als gelesen markiert werden",
"instant": "Sofort",
"delay_3s": "Nach 3 Sekunden",
"delay_5s": "Nach 5 Sekunden",
"never": "Nie"
},
"delete_action": {
"label": "Löschaktion",
"description": "Was passiert, wenn Sie eine E-Mail löschen",
"trash": "In Papierkorb verschieben",
"permanent": "Dauerhaft löschen"
},
"show_preview": {
"label": "Vorschautext anzeigen",
"description": "E-Mail-Vorschau in der Liste anzeigen"
},
"emails_per_page": {
"label": "E-Mails pro Seite",
"description": "Anzahl der E-Mails, die auf einmal geladen werden",
"25": "25 E-Mails",
"50": "50 E-Mails",
"100": "100 E-Mails"
},
"external_content": {
"label": "Externe Inhalte",
"description": "Wie mit Bildern und externen Inhalten umgegangen werden soll",
"ask": "Immer fragen",
"block": "Immer blockieren",
"allow": "Immer erlauben"
},
"trusted_senders": {
"label": "Vertrauenswürdige Absender",
"description": "Verwalten Sie Absender, deren Bilder automatisch geladen werden",
"count_zero": "Keine",
"count_one": "1 Absender",
"count_other": "{count} Absender",
"modal_title": "Vertrauenswürdige Absender",
"empty_title": "Noch keine vertrauenswürdigen Absender",
"empty_description": "Wenn Sie eine E-Mail mit blockierten Bildern anzeigen, klicken Sie auf \"Diesem Absender immer vertrauen\", um sie hier hinzuzufügen.",
"add_manually": "Absender manuell hinzufügen",
"add_button": "Hinzufügen",
"add_placeholder": "E-Mail-Adresse eingeben",
"search_placeholder": "Absender suchen...",
"no_results": "Keine Absender entsprechen Ihrer Suche",
"remove": "Entfernen",
"close": "Schließen",
"invalid_email": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"already_added": "Dieser Absender ist bereits vertrauenswürdig"
}
},
"composer": {
"title": "Editor",
"description": "Konfigurieren Sie die E-Mail-Erstellungseinstellungen",
"autosave": {
"label": "Automatisches Speichern",
"description": "Wie oft Entwürfe automatisch gespeichert werden",
"30s": "Alle 30 Sekunden",
"1m": "Jede Minute",
"2m": "Alle 2 Minuten",
"5m": "Alle 5 Minuten"
},
"send_confirmation": {
"label": "Sendebestätigung",
"description": "Vor dem Senden von E-Mails um Bestätigung bitten"
},
"default_reply": {
"label": "Standard-Antwortmodus",
"description": "Standardaktion beim Klicken auf Antworten",
"reply": "Antworten",
"reply_all": "Allen antworten"
}
},
"privacy": {
"title": "Datenschutz & Sicherheit",
"description": "Verwalten Sie Ihre Datenschutz- und Sicherheitseinstellungen",
"external_images": {
"label": "Externe Bilder blockieren",
"description": "Verhindern Sie Tracking durch externe Bilder"
},
"session_timeout": {
"label": "Sitzungszeitüberschreitung",
"description": "Automatisch abmelden nach Inaktivität",
"never": "Nie",
"30m": "30 Minuten",
"1h": "1 Stunde",
"4h": "4 Stunden"
},
"clear_cache": {
"label": "Cache leeren",
"description": "Zwischengespeicherte Daten und temporäre Dateien entfernen",
"button": "Cache leeren",
"confirm": "Sind Sie sicher, dass Sie den Cache leeren möchten?",
"success": "Cache erfolgreich geleert"
}
},
"account": {
"title": "Konto",
"description": "Zeigen Sie Ihre Kontoinformationen an",
"email": {
"label": "E-Mail-Adresse",
"value": "{{email}}"
},
"server": {
"label": "JMAP-Server",
"value": "{{server}}"
},
"storage": {
"label": "Speichernutzung",
"used": "{{used}} von {{total}} verwendet",
"percentage": "{{percent}}% verwendet"
},
"last_sync": {
"label": "Letzte Synchronisierung",
"value": "{{time}}"
}
},
"identities": {
"title": "Sendeidentitäten",
"description": "Verwalten Sie E-Mail-Adressen, von denen Sie senden können",
"identities_count": {
"label": "Ihre Identitäten",
"description": "Für das Senden konfigurierte E-Mail-Adressen",
"count_zero": "Keine Identitäten",
"count_one": "1 Identität",
"count_other": "{{count}} Identitäten"
},
"manage": "Identitäten verwalten",
"sub_addressing": {
"label": "Sub-Adressierung",
"description": "Verwenden Sie Tags wie benutzer+tag@domain.de, um eingehende E-Mails zu organisieren",
"learn_more": "Mehr erfahren"
}
},
"advanced": {
"title": "Erweitert",
"description": "Erweiterte Optionen und Entwicklereinstellungen",
"debug_mode": {
"label": "Debug-Modus",
"description": "Detaillierte Protokollierung zur Fehlerbehebung aktivieren"
},
"keyboard_shortcuts": {
"label": "Tastaturkürzel",
"description": "Verfügbare Tastaturkürzel anzeigen",
"button": "Tastaturkürzel anzeigen"
},
"reset_settings": {
"label": "Einstellungen zurücksetzen",
"description": "Alle Einstellungen auf Standardwerte zurücksetzen",
"button": "Auf Standard zurücksetzen"
},
"export_settings": {
"label": "Einstellungen exportieren",
"description": "Ihre Einstellungen als JSON herunterladen",
"button": "Exportieren"
},
"import_settings": {
"label": "Einstellungen importieren",
"description": "Einstellungen aus JSON-Datei hochladen",
"button": "Importieren"
}
}
},
"errors": {
"page_error_title": "Etwas ist schiefgelaufen",
"page_error_description": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut oder kehren Sie zur Startseite zurück.",
"sidebar_error": "Postfächer können nicht geladen werden",
"email_list_error": "E-Mails können nicht geladen werden",
"viewer_error_title": "E-Mail kann nicht angezeigt werden",
"viewer_error_description": "Beim Rendern dieser E-Mail ist ein Problem aufgetreten. Sie enthält möglicherweise nicht unterstützte Inhalte.",
"composer_error": "Editor kann nicht geladen werden",
"settings_error_title": "Einstellungen nicht verfügbar",
"settings_error_description": "Einstellungen können nicht geladen werden. Ihre Einstellungen werden möglicherweise nicht gespeichert.",
"try_again": "Erneut versuchen",
"reload": "Neu laden",
"reload_emails": "E-Mails neu laden",
"reload_settings": "Einstellungen neu laden",
"retry": "Wiederholen",
"go_home": "Zum Posteingang"
},
"context_menu": {
"reply": "Antworten",
"reply_all": "Allen antworten",
"forward": "Weiterleiten",
"mark_read": "Als gelesen markieren",
"mark_unread": "Als ungelesen markieren",
"star": "Stern hinzufügen",
"unstar": "Stern entfernen",
"move_to": "Verschieben nach...",
"archive": "Archivieren",
"delete": "Löschen",
"mark_as_spam": "Spam melden",
"not_spam": "Kein Spam",
"color_tag": "Farb-Tag",
"remove_color": "Farbe entfernen",
"items_selected": "{{count}} E-Mails ausgewählt"
},
"shortcuts": {
"title": "Tastaturkürzel",
"tip": "Drücken Sie ? jederzeit, um diese Hilfe anzuzeigen",
"sections": {
"navigation": "Navigation",
"actions": "E-Mail-Aktionen",
"global": "Global",
"threads": "Unterhaltungen"
},
"navigation": {
"next_email": "Nächste E-Mail",
"previous_email": "Vorherige E-Mail",
"open_email": "E-Mail öffnen",
"close_email": "Schließen / Auswahl aufheben"
},
"actions": {
"reply": "Antworten",
"reply_all": "Allen antworten",
"forward": "Weiterleiten",
"star": "Stern umschalten",
"archive": "Archivieren",
"delete": "Löschen",
"mark_unread": "Als ungelesen markieren",
"mark_read": "Als gelesen markieren",
"toggle_spam": "Spam melden / Kein Spam"
},
"global": {
"compose": "Neue E-Mail verfassen",
"search": "Suche fokussieren",
"help": "Tastaturkürzel anzeigen",
"refresh": "E-Mails aktualisieren",
"select_all": "Alle auswählen"
},
"threads": {
"expand_collapse": "Unterhaltung erweitern/einklappen"
}
},
"threads": {
"messages_one": "{count} Nachricht",
"messages_other": "{count} Nachrichten",
"expand": "Unterhaltung erweitern",
"collapse": "Unterhaltung einklappen",
"loading": "Unterhaltung wird geladen...",
"mark_read": "Unterhaltung als gelesen markieren",
"mark_unread": "Unterhaltung als ungelesen markieren",
"archive": "Unterhaltung archivieren",
"delete": "Unterhaltung löschen",
"star": "Unterhaltung mit Stern markieren",
"unstar": "Stern von Unterhaltung entfernen"
},
"identities": {
"modal_title": "Sendeidentitäten verwalten",
"create_new": "Neue Identität erstellen",
"edit_identity": "Identität bearbeiten",
"delete_confirm": "Diese Identität löschen? Dies kann nicht rückgängig gemacht werden.",
"cannot_delete": "Diese Identität kann nicht gelöscht werden",
"primary_identity": "Primär",
"no_identities": "Keine Identitäten gefunden",
"display": {
"reply_to": "Antwort an:",
"bcc": "BCC:",
"signature": "Signatur:",
"preview": "Vorschau:"
},
"validation_errors": {
"invalid_emails": "Ungültige E-Mails: {emails}",
"unknown_error": "Unbekannter Fehler"
},
"form": {
"name_label": "Anzeigename",
"name_placeholder": "z.B. Geschäftliche E-Mail, Privat",
"name_required": "Name ist erforderlich",
"email_label": "E-Mail-Adresse",
"email_placeholder": "ihre.email@beispiel.de",
"email_required": "E-Mail ist erforderlich",
"email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"email_immutable": "E-Mail-Adresse kann nach der Erstellung nicht geändert werden",
"reply_to_label": "Antwort an (optional)",
"reply_to_placeholder": "andere@email.de",
"bcc_label": "Automatisches BCC (optional)",
"bcc_placeholder": "archiv@email.de",
"text_signature_label": "Text-Signatur",
"html_signature_label": "HTML-Signatur",
"save": "Identität speichern",
"cancel": "Abbrechen",
"creating": "Wird erstellt...",
"updating": "Wird aktualisiert..."
},
"sub_address": {
"button_tooltip": "Sub-Adresse verwenden",
"popover_title": "Sub-Adress-Tag hinzufügen",
"tag_input_placeholder": "Tag eingeben (z.B. einkauf)",
"preview_label": "Vorschau:",
"recent_tags": "Letzte Tags",
"suggested_tags": "Vorgeschlagen",
"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",
"validation": {
"empty": "Tag darf nicht leer sein",
"too_long": "Tag darf maximal {max} Zeichen lang sein",
"invalid_chars": "Tag darf nur Buchstaben, Zahlen und Bindestriche enthalten"
}
},
"badge": {
"sent_via": "über",
"sub_address_tag": "Gesendet mit Sub-Adresse: {tag}",
"identity_name": "Gesendet mit Identität: {name}",
"identity_short": "über {name}",
"subaddress_tag": "+{tag}"
}
}
}
+170 -5
View File
@@ -52,7 +52,13 @@
"expand": "Expand", "expand": "Expand",
"collapse": "Collapse", "collapse": "Collapse",
"expand_tooltip": "Expand", "expand_tooltip": "Expand",
"collapse_tooltip": "Collapse" "collapse_tooltip": "Collapse",
"mobile": {
"search": "Search",
"compose": "Compose",
"go_back": "Go back"
},
"clear_search": "Clear search"
}, },
"email_list": { "email_list": {
"no_emails": "No emails", "no_emails": "No emails",
@@ -63,7 +69,19 @@
"to_recipients": "To {{count}} recipients", "to_recipients": "To {{count}} recipients",
"and_others": "and {{count}} others", "and_others": "and {{count}} others",
"draft": "Draft", "draft": "Draft",
"starred": "Starred" "starred": "Starred",
"conversations_count": "{count} of {total} conversations",
"conversations_count_plus": "{count}+ conversations",
"conversations_count_simple": "{count} conversations",
"no_conversations": "No conversations",
"loading_more": "Loading more emails...",
"no_more_emails": "No more emails to load",
"batch_actions": {
"mark_read": "Mark as read",
"mark_unread": "Mark as unread",
"delete": "Delete",
"clear_selection": "Clear selection"
}
}, },
"email_viewer": { "email_viewer": {
"no_email_selected": "No email selected", "no_email_selected": "No email selected",
@@ -129,6 +147,9 @@
"minutes": "minutes" "minutes": "minutes"
}, },
"unknown_sender": "Unknown", "unknown_sender": "Unknown",
"recipient_me": "me",
"recipient_and_others": "{name} and {count} others",
"recipient_to_prefix": "To:",
"authentication": { "authentication": {
"title": "Authentication", "title": "Authentication",
"status": { "status": {
@@ -167,7 +188,35 @@
"green": "Green", "green": "Green",
"blue": "Blue", "blue": "Blue",
"purple": "Purple", "purple": "Purple",
"pink": "Pink",
"none": "None" "none": "None"
},
"tooltips": {
"reply": "Reply",
"archive": "Archive",
"delete": "Delete"
},
"spam": {
"button_title": "Report spam",
"not_spam_title": "Mark as legitimate",
"toast_success": "Moved to Junk",
"toast_batch": "{count} emails moved to Junk",
"toast_undo": "Undo",
"toast_not_spam_success": "Moved to Inbox",
"toast_not_spam_batch": "{count} emails moved to Inbox",
"error": "Failed to report spam",
"error_not_spam": "Failed to restore email"
},
"unsubscribe_banner": {
"label": "Newsletter",
"button": "Unsubscribe",
"confirm_title": "Unsubscribe from this sender?",
"confirm_button": "Confirm",
"cancel": "Cancel",
"success_http": "Unsubscribe page opened in new tab",
"success_mailto": "Unsubscribe request sent to your email client",
"error": "Unable to unsubscribe",
"dismiss": "Dismiss"
} }
}, },
"email_composer": { "email_composer": {
@@ -200,6 +249,12 @@
"bcc_label": "Bcc:", "bcc_label": "Bcc:",
"subject_label": "Subject:", "subject_label": "Subject:",
"file_size_kb": "KB", "file_size_kb": "KB",
"prefix": {
"forward": "Fwd:",
"reply": "Re:"
},
"no_subject": "(No Subject)",
"unknown_sender": "Unknown",
"quote": { "quote": {
"reply_header": "On {{date}}, {{sender}} wrote:", "reply_header": "On {{date}}, {{sender}} wrote:",
"forward_header": "---------- Forwarded message ----------", "forward_header": "---------- Forwarded message ----------",
@@ -207,7 +262,8 @@
"date": "Date: {{date}}", "date": "Date: {{date}}",
"subject": "Subject: {{subject}}", "subject": "Subject: {{subject}}",
"to": "To: {{recipients}}" "to": "To: {{recipients}}"
} },
"remove_sub_address": "Remove sub-address"
}, },
"common": { "common": {
"loading": "Loading...", "loading": "Loading...",
@@ -243,7 +299,20 @@
"error_loading": "Failed to load emails", "error_loading": "Failed to load emails",
"new_email": "New email", "new_email": "New email",
"new_email_from": "From {sender}", "new_email_from": "From {sender}",
"click_to_view": "Click to view" "click_to_view": "Click to view",
"email_moved": "Email moved",
"emails_moved": "{count} emails moved",
"moved_to_mailbox": "Moved to {mailbox}",
"move_failed": "Move failed",
"move_error": "Could not move emails to the selected folder",
"identity_created": "Identity created successfully",
"identity_updated": "Identity updated successfully",
"identity_deleted": "Identity deleted",
"identity_create_failed": "Failed to create identity: {{error}}",
"identity_update_failed": "Failed to update identity: {{error}}",
"identity_delete_failed": "Failed to delete identity: {{error}}",
"identity_unauthorized": "You are not authorized to send from this email address",
"identity_not_found": "Identity not found"
}, },
"date": { "date": {
"today": "Today", "today": "Today",
@@ -264,9 +333,21 @@
"title": "Language", "title": "Language",
"english": "English", "english": "English",
"french": "Français", "french": "Français",
"japanese": "日本語",
"spanish": "Español",
"italian": "Italiano",
"german": "Deutsch",
"dutch": "Nederlands",
"portuguese": "Português",
"select_language": "Select language", "select_language": "Select language",
"switch_to_english": "Switch to English", "switch_to_english": "Switch to English",
"switch_to_french": "Switch to French", "switch_to_french": "Switch to French",
"switch_to_japanese": "Switch to Japanese",
"switch_to_spanish": "Switch to Spanish",
"switch_to_italian": "Switch to Italian",
"switch_to_german": "Switch to German",
"switch_to_dutch": "Switch to Dutch",
"switch_to_portuguese": "Switch to Portuguese",
"switching": "Changing language..." "switching": "Changing language..."
}, },
"settings": { "settings": {
@@ -283,6 +364,7 @@
"composer": "Composer", "composer": "Composer",
"privacy": "Privacy & Security", "privacy": "Privacy & Security",
"account": "Account", "account": "Account",
"identities": "Identities",
"advanced": "Advanced" "advanced": "Advanced"
}, },
"appearance": { "appearance": {
@@ -468,6 +550,23 @@
"value": "{{time}}" "value": "{{time}}"
} }
}, },
"identities": {
"title": "Sending Identities",
"description": "Manage email addresses you can send from",
"identities_count": {
"label": "Your Identities",
"description": "Email addresses configured for sending",
"count_zero": "No identities",
"count_one": "1 identity",
"count_other": "{{count}} identities"
},
"manage": "Manage Identities",
"sub_addressing": {
"label": "Sub-Addressing",
"description": "Use tags like user+tag@domain.com to organize incoming mail",
"learn_more": "Learn More"
}
},
"advanced": { "advanced": {
"title": "Advanced", "title": "Advanced",
"description": "Advanced options and developer settings", "description": "Advanced options and developer settings",
@@ -525,6 +624,8 @@
"move_to": "Move to...", "move_to": "Move to...",
"archive": "Archive", "archive": "Archive",
"delete": "Delete", "delete": "Delete",
"mark_as_spam": "Report spam",
"not_spam": "Not spam",
"color_tag": "Color Tag", "color_tag": "Color Tag",
"remove_color": "Remove Color", "remove_color": "Remove Color",
"items_selected": "{{count}} emails selected" "items_selected": "{{count}} emails selected"
@@ -552,7 +653,8 @@
"archive": "Archive", "archive": "Archive",
"delete": "Delete", "delete": "Delete",
"mark_unread": "Mark as unread", "mark_unread": "Mark as unread",
"mark_read": "Mark as read" "mark_read": "Mark as read",
"toggle_spam": "Report spam / Not spam"
}, },
"global": { "global": {
"compose": "Compose new email", "compose": "Compose new email",
@@ -577,5 +679,68 @@
"delete": "Delete conversation", "delete": "Delete conversation",
"star": "Star conversation", "star": "Star conversation",
"unstar": "Unstar conversation" "unstar": "Unstar conversation"
},
"identities": {
"modal_title": "Manage Sending Identities",
"create_new": "Create New Identity",
"edit_identity": "Edit Identity",
"delete_confirm": "Delete this identity? This cannot be undone.",
"cannot_delete": "This identity cannot be deleted",
"primary_identity": "Primary",
"no_identities": "No identities found",
"display": {
"reply_to": "Reply-To:",
"bcc": "BCC:",
"signature": "Signature:",
"preview": "Preview:"
},
"validation_errors": {
"invalid_emails": "Invalid emails: {emails}",
"unknown_error": "Unknown error"
},
"form": {
"name_label": "Display Name",
"name_placeholder": "e.g., Work Email, Personal",
"name_required": "Name is required",
"email_label": "Email Address",
"email_placeholder": "your.email@example.com",
"email_required": "Email is required",
"email_invalid": "Please enter a valid email address",
"email_immutable": "Email address cannot be changed after creation",
"reply_to_label": "Reply-To (optional)",
"reply_to_placeholder": "different@email.com",
"bcc_label": "Auto BCC (optional)",
"bcc_placeholder": "archive@email.com",
"text_signature_label": "Text Signature",
"html_signature_label": "HTML Signature",
"save": "Save Identity",
"cancel": "Cancel",
"creating": "Creating...",
"updating": "Updating..."
},
"sub_address": {
"button_tooltip": "Use sub-address",
"popover_title": "Add Sub-Address Tag",
"tag_input_placeholder": "Enter tag (e.g., shopping)",
"preview_label": "Preview:",
"recent_tags": "Recent Tags",
"suggested_tags": "Suggested",
"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",
"validation": {
"empty": "Tag cannot be empty",
"too_long": "Tag must be {max} characters or less",
"invalid_chars": "Tag must contain only letters, numbers, and dashes"
}
},
"badge": {
"sent_via": "via",
"sub_address_tag": "Sent using sub-address: {tag}",
"identity_name": "Sent using identity: {name}",
"identity_short": "via {name}",
"subaddress_tag": "+{tag}"
}
} }
} }
+746
View File
@@ -0,0 +1,746 @@
{
"login": {
"title": "Correo Web",
"username_label": "Correo electrónico",
"username_placeholder": "usuario@ejemplo.com",
"password_label": "Contraseña",
"password_placeholder": "Ingrese su contraseña",
"sign_in": "Iniciar sesión",
"signing_in": "Iniciando sesión...",
"loading": "Cargando...",
"error": {
"invalid_credentials": "Correo electrónico o contraseña inválidos",
"connection_failed": "No se pudo conectar con el servidor",
"generic": "Ocurrió un error. Por favor, inténtelo de nuevo."
},
"config_error": {
"title": "Error de Configuración",
"fetch_failed": "No se pudo cargar la configuración de la aplicación. Por favor, inténtelo más tarde.",
"server_not_configured": "El servidor de correo no ha sido configurado. Por favor, contacte a su administrador."
},
"remove_from_history": "Eliminar del historial"
},
"sidebar": {
"close": "Cerrar",
"compose": "Redactar",
"search_placeholder": "Buscar correo...",
"storage": "Almacenamiento",
"sign_out": "Cerrar sesión",
"settings": "Configuración",
"loading_mailboxes": "Cargando buzones...",
"push_connected": "Actualizaciones en tiempo real activas",
"push_disconnected": "Actualizaciones en tiempo real inactivas",
"theme": {
"light": "Modo claro",
"dark": "Modo oscuro",
"system": "Tema del sistema"
},
"language": {
"title": "Idioma"
},
"mailboxes": {
"inbox": "Bandeja de entrada",
"sent": "Enviados",
"drafts": "Borradores",
"trash": "Papelera",
"archive": "Archivo",
"starred": "Destacados",
"all_mail": "Todo el correo",
"spam": "Spam",
"important": "Importante"
},
"expand": "Expandir",
"collapse": "Contraer",
"expand_tooltip": "Expandir",
"collapse_tooltip": "Contraer",
"mobile": {
"search": "Buscar",
"compose": "Redactar",
"go_back": "Volver"
},
"clear_search": "Limpiar búsqueda"
},
"email_list": {
"no_emails": "Sin correos",
"no_emails_description": "Comience redactando un nuevo correo",
"loading": "Cargando correos...",
"unread": "no leído",
"to_me": "Para mí",
"to_recipients": "Para {{count}} destinatarios",
"and_others": "y {{count}} más",
"draft": "Borrador",
"starred": "Destacado",
"conversations_count": "{count} de {total} conversaciones",
"conversations_count_plus": "{count}+ conversaciones",
"conversations_count_simple": "{count} conversaciones",
"no_conversations": "Sin conversaciones",
"loading_more": "Cargando más correos...",
"no_more_emails": "No hay más correos para cargar",
"batch_actions": {
"mark_read": "Marcar como leído",
"mark_unread": "Marcar como no leído",
"delete": "Eliminar",
"clear_selection": "Limpiar selección"
}
},
"email_viewer": {
"no_email_selected": "Ningún correo seleccionado",
"no_email_description": "Seleccione un correo de la lista para verlo aquí",
"no_conversation_selected": "Ninguna conversación seleccionada",
"no_conversation_description": "Elija una conversación de la lista para leerla aquí",
"no_subject": "(Sin Asunto)",
"loading_email": "Cargando correo...",
"loading": "Cargando...",
"reply": "Responder",
"reply_all": "Responder a todos",
"forward": "Reenviar",
"delete": "Eliminar",
"archive": "Archivar",
"star": "Destacar",
"unstar": "Quitar destacado",
"mark_unread": "Marcar como no leído",
"mark_read": "Marcar como leído",
"print": "Imprimir",
"view_source": "Ver código fuente",
"email_source": "Código Fuente del Correo",
"copy_source": "Copiar al portapapeles",
"source_copied": "Código fuente copiado al portapapeles",
"attachments": "Archivos adjuntos",
"important": "Importante",
"download": "Descargar",
"from": "De",
"to": "Para",
"cc": "CC",
"bcc": "CCO",
"date": "Fecha",
"subject": "Asunto",
"show_details": "Mostrar detalles",
"hide_details": "Ocultar detalles",
"external_content_warning": "Las imágenes y el contenido externo han sido bloqueados",
"load_external_content": "Cargar imágenes",
"trust_sender": "Confiar siempre en este remitente",
"back_to_list": "Volver a la lista",
"message_details": "Detalles del Mensaje",
"more_reply_options": "Más opciones de respuesta",
"set_color": "Establecer color",
"more_actions": "Más acciones",
"remove_color": "Eliminar color",
"more_count": "+{count} más",
"characters_count": "{count} caracteres",
"quick_reply_placeholder": "Escriba una respuesta rápida...",
"more_options": "Más opciones",
"sending": "Enviando...",
"security_authentication": "Seguridad y Autenticación",
"technical_details": "Detalles Técnicos",
"message_id_label": "ID del Mensaje:",
"reply_to_label": "Responder a:",
"delivery_time_label": "Hora de entrega:",
"conversation_part_label": "Parte de conversación:",
"previous_messages": "{count} mensaje anterior",
"previous_messages_plural": "{count} mensajes anteriores",
"time": {
"day": "día",
"days": "días",
"hour": "hora",
"hours": "horas",
"minute": "minuto",
"minutes": "minutos"
},
"unknown_sender": "Desconocido",
"recipient_me": "yo",
"recipient_and_others": "{name} y {count} más",
"recipient_to_prefix": "Para:",
"authentication": {
"title": "Autenticación",
"status": {
"verified": "Verificado",
"warning": "Advertencia",
"none": "No autenticado"
},
"spf": {
"pass": "SPF Aprobado",
"fail": "SPF Fallido",
"none": "Sin SPF"
},
"dkim": {
"pass": "DKIM Válido",
"fail": "DKIM Inválido",
"none": "Sin DKIM"
},
"dmarc": {
"pass": "DMARC Aprobado",
"fail": "DMARC Fallido",
"none": "Sin DMARC"
},
"spam_score": "Puntuación de Spam"
},
"headers": {
"routing": "Enrutamiento",
"received": "Recibido",
"message_id": "ID del Mensaje",
"list_info": "Información de Lista"
},
"color_tag": {
"title": "Etiqueta de Color",
"red": "Rojo",
"orange": "Naranja",
"yellow": "Amarillo",
"green": "Verde",
"blue": "Azul",
"purple": "Morado",
"pink": "Rosa",
"none": "Ninguno"
},
"tooltips": {
"reply": "Responder",
"archive": "Archivar",
"delete": "Eliminar"
},
"spam": {
"button_title": "Reportar spam",
"not_spam_title": "Marcar como legítimo",
"toast_success": "Movido a Correo no deseado",
"toast_batch": "{count} correos movidos a Correo no deseado",
"toast_undo": "Deshacer",
"toast_not_spam_success": "Movido a Bandeja de entrada",
"toast_not_spam_batch": "{count} correos movidos a Bandeja de entrada",
"error": "No se pudo reportar spam",
"error_not_spam": "No se pudo restaurar el correo"
},
"unsubscribe_banner": {
"label": "Boletín",
"button": "Cancelar suscripción",
"confirm_title": "¿Cancelar suscripción de este remitente?",
"confirm_button": "Confirmar",
"cancel": "Cancelar",
"success_http": "Página de cancelación de suscripción abierta en nueva pestaña",
"success_mailto": "Solicitud de cancelación enviada a su cliente de correo",
"error": "No se pudo cancelar la suscripción",
"dismiss": "Descartar"
}
},
"email_composer": {
"new_message": "Nuevo Mensaje",
"reply": "Responder",
"reply_all": "Responder a Todos",
"forward": "Reenviar",
"reply_to": "Responder",
"reply_all_to": "Responder a Todos",
"forward_message": "Reenviar",
"from": "De",
"to": "Para",
"cc": "CC",
"bcc": "CCO",
"subject": "Asunto",
"body_placeholder": "Escriba su mensaje...",
"send": "Enviar",
"cancel": "Cancelar",
"attach": "Adjuntar",
"discard": "Descartar",
"discard_draft_confirm": "Tiene cambios sin guardar. ¿Desea descartar este borrador?",
"saving": "Guardando...",
"draft_saved": "Borrador guardado",
"save_failed": "Error al guardar",
"to_placeholder": "Direcciones de correo de destinatarios (separadas por comas)",
"cc_placeholder": "Destinatarios CC (separados por comas)",
"bcc_placeholder": "Destinatarios CCO (separados por comas)",
"subject_placeholder": "Asunto",
"cc_label": "CC:",
"bcc_label": "CCO:",
"subject_label": "Asunto:",
"file_size_kb": "KB",
"prefix": {
"forward": "Rvf:",
"reply": "Re:"
},
"no_subject": "(Sin Asunto)",
"unknown_sender": "Desconocido",
"quote": {
"reply_header": "El {{date}}, {{sender}} escribió:",
"forward_header": "---------- Mensaje reenviado ----------",
"from": "De: {{sender}}",
"date": "Fecha: {{date}}",
"subject": "Asunto: {{subject}}",
"to": "Para: {{recipients}}"
},
"remove_sub_address": "Eliminar sub-dirección"
},
"common": {
"loading": "Cargando...",
"error": "Error",
"success": "Éxito",
"cancel": "Cancelar",
"save": "Guardar",
"delete": "Eliminar",
"edit": "Editar",
"close": "Cerrar",
"search": "Buscar",
"refresh": "Actualizar",
"settings": "Configuración",
"help": "Ayuda",
"logout": "Cerrar sesión",
"yes": "Sí",
"no": "No",
"unknown": "Desconocido",
"app_title": "Correo Web"
},
"notifications": {
"email_sent": "Correo enviado exitosamente",
"email_deleted": "Correo eliminado",
"email_archived": "Correo archivado",
"email_starred": "Correo destacado",
"email_unstarred": "Correo sin destacar",
"email_marked_read": "Correo marcado como leído",
"email_marked_unread": "Correo marcado como no leído",
"copied_to_clipboard": "Copiado al portapapeles",
"source_copied": "Código fuente copiado al portapapeles",
"error_sending": "Error al enviar el correo",
"error_deleting": "Error al eliminar el correo",
"error_loading": "Error al cargar los correos",
"new_email": "Nuevo correo",
"new_email_from": "De {sender}",
"click_to_view": "Haga clic para ver",
"email_moved": "Correo movido",
"emails_moved": "{count} correos movidos",
"moved_to_mailbox": "Movido a {mailbox}",
"move_failed": "Error al mover",
"move_error": "No se pudieron mover los correos a la carpeta seleccionada",
"identity_created": "Identidad creada exitosamente",
"identity_updated": "Identidad actualizada exitosamente",
"identity_deleted": "Identidad eliminada",
"identity_create_failed": "Error al crear identidad: {{error}}",
"identity_update_failed": "Error al actualizar identidad: {{error}}",
"identity_delete_failed": "Error al eliminar identidad: {{error}}",
"identity_unauthorized": "No está autorizado para enviar desde esta dirección de correo",
"identity_not_found": "Identidad no encontrada"
},
"date": {
"today": "Hoy",
"yesterday": "Ayer",
"this_week": "Esta semana",
"last_week": "Semana pasada",
"this_month": "Este mes",
"older": "Más antiguo",
"just_now": "Justo ahora",
"minutes_ago": "hace {{count}} minuto",
"minutes_ago_plural": "hace {{count}} minutos",
"hours_ago": "hace {{count}} hora",
"hours_ago_plural": "hace {{count}} horas",
"days_ago": "hace {{count}} día",
"days_ago_plural": "hace {{count}} días"
},
"language": {
"title": "Idioma",
"english": "English",
"french": "Français",
"japanese": "日本語",
"spanish": "Español",
"italian": "Italiano",
"german": "Deutsch",
"dutch": "Nederlands",
"portuguese": "Português",
"select_language": "Seleccionar idioma",
"switch_to_english": "Cambiar a inglés",
"switch_to_french": "Cambiar a francés",
"switch_to_japanese": "Cambiar a japonés",
"switch_to_spanish": "Cambiar a español",
"switch_to_italian": "Cambiar a italiano",
"switch_to_german": "Cambiar a alemán",
"switch_to_dutch": "Cambiar a neerlandés",
"switch_to_portuguese": "Cambiar a portugués",
"switching": "Cambiando idioma..."
},
"settings": {
"title": "Configuración",
"back_to_mail": "Volver al Correo",
"save_success": "Configuración guardada exitosamente",
"import_success": "Configuración importada exitosamente",
"import_error": "Error al importar la configuración",
"reset_confirm": "¿Está seguro de que desea restablecer toda la configuración a los valores predeterminados?",
"tabs": {
"appearance": "Apariencia",
"language": "Idioma y Región",
"email": "Comportamiento del Correo",
"composer": "Editor",
"privacy": "Privacidad y Seguridad",
"account": "Cuenta",
"identities": "Identidades",
"advanced": "Avanzado"
},
"appearance": {
"title": "Apariencia",
"description": "Personalice la apariencia de su correo web",
"theme": {
"label": "Tema",
"description": "Elija su esquema de color preferido",
"light": "Claro",
"dark": "Oscuro",
"system": "Sistema"
},
"language": {
"label": "Idioma",
"description": "Elija su idioma preferido"
},
"font_size": {
"label": "Tamaño de Fuente",
"description": "Ajuste el tamaño del texto para mejor legibilidad",
"small": "Pequeño",
"medium": "Mediano",
"large": "Grande"
},
"list_density": {
"label": "Densidad de Lista",
"description": "Controle el espaciado en las listas de correo",
"compact": "Compacto",
"regular": "Regular",
"comfortable": "Cómodo"
},
"animations": {
"label": "Habilitar Animaciones",
"description": "Mostrar transiciones y efectos suaves"
}
},
"language_region": {
"title": "Idioma y Región",
"description": "Configure las preferencias de idioma y región",
"language": {
"label": "Idioma",
"description": "Elija su idioma preferido",
"english": "English",
"french": "Français"
},
"date_format": {
"label": "Formato de Fecha",
"description": "Cómo se deben mostrar las fechas",
"regional": "Regional",
"iso": "ISO 8601",
"custom": "Personalizado"
},
"time_format": {
"label": "Formato de Hora",
"description": "Elija entre reloj de 12 o 24 horas",
"12h": "12 horas",
"24h": "24 horas"
},
"first_day": {
"label": "Primer Día de la Semana",
"description": "Iniciar la semana en domingo o lunes",
"sunday": "Domingo",
"monday": "Lunes"
}
},
"email_behavior": {
"title": "Comportamiento del Correo",
"description": "Configure cómo se manejan los correos",
"mark_read": {
"label": "Marcar como Leído",
"description": "Cuándo marcar correos como leídos al abrirlos",
"instant": "Instantáneamente",
"delay_3s": "Después de 3 segundos",
"delay_5s": "Después de 5 segundos",
"never": "Nunca"
},
"delete_action": {
"label": "Acción al Eliminar",
"description": "Qué sucede cuando elimina un correo",
"trash": "Mover a Papelera",
"permanent": "Eliminar Permanentemente"
},
"show_preview": {
"label": "Mostrar Vista Previa",
"description": "Mostrar vista previa del correo en la lista"
},
"emails_per_page": {
"label": "Correos por Página",
"description": "Número de correos a cargar a la vez",
"25": "25 correos",
"50": "50 correos",
"100": "100 correos"
},
"external_content": {
"label": "Contenido Externo",
"description": "Cómo manejar imágenes y contenido externo",
"ask": "Preguntar siempre",
"block": "Bloquear siempre",
"allow": "Permitir siempre"
},
"trusted_senders": {
"label": "Remitentes de Confianza",
"description": "Administre remitentes cuyas imágenes se cargan automáticamente",
"count_zero": "Ninguno",
"count_one": "1 remitente",
"count_other": "{count} remitentes",
"modal_title": "Remitentes de Confianza",
"empty_title": "Aún no hay remitentes de confianza",
"empty_description": "Al ver un correo con imágenes bloqueadas, haga clic en \"Confiar siempre en este remitente\" para agregarlo aquí.",
"add_manually": "Agregar remitente manualmente",
"add_button": "Agregar",
"add_placeholder": "Ingrese dirección de correo",
"search_placeholder": "Buscar remitentes...",
"no_results": "Ningún remitente coincide con su búsqueda",
"remove": "Eliminar",
"close": "Cerrar",
"invalid_email": "Por favor ingrese una dirección de correo válida",
"already_added": "Este remitente ya es de confianza"
}
},
"composer": {
"title": "Editor",
"description": "Configure los ajustes de redacción de correos",
"autosave": {
"label": "Intervalo de Guardado Automático",
"description": "Con qué frecuencia guardar borradores automáticamente",
"30s": "Cada 30 segundos",
"1m": "Cada minuto",
"2m": "Cada 2 minutos",
"5m": "Cada 5 minutos"
},
"send_confirmation": {
"label": "Confirmación de Envío",
"description": "Solicitar confirmación antes de enviar correos"
},
"default_reply": {
"label": "Modo de Respuesta Predeterminado",
"description": "Acción predeterminada al hacer clic en responder",
"reply": "Responder",
"reply_all": "Responder a Todos"
}
},
"privacy": {
"title": "Privacidad y Seguridad",
"description": "Administre su configuración de privacidad y seguridad",
"external_images": {
"label": "Bloquear Imágenes Externas",
"description": "Prevenir el seguimiento a través de imágenes externas"
},
"session_timeout": {
"label": "Tiempo de Espera de Sesión",
"description": "Cerrar sesión automáticamente después de inactividad",
"never": "Nunca",
"30m": "30 minutos",
"1h": "1 hora",
"4h": "4 horas"
},
"clear_cache": {
"label": "Limpiar Caché",
"description": "Eliminar datos en caché y archivos temporales",
"button": "Limpiar Caché",
"confirm": "¿Está seguro de que desea limpiar la caché?",
"success": "Caché limpiada exitosamente"
}
},
"account": {
"title": "Cuenta",
"description": "Vea la información de su cuenta",
"email": {
"label": "Dirección de Correo",
"value": "{{email}}"
},
"server": {
"label": "Servidor JMAP",
"value": "{{server}}"
},
"storage": {
"label": "Uso de Almacenamiento",
"used": "{{used}} de {{total}} usado",
"percentage": "{{percent}}% usado"
},
"last_sync": {
"label": "Última Sincronización",
"value": "{{time}}"
}
},
"identities": {
"title": "Identidades de Envío",
"description": "Administre las direcciones de correo desde las que puede enviar",
"identities_count": {
"label": "Sus Identidades",
"description": "Direcciones de correo configuradas para enviar",
"count_zero": "Sin identidades",
"count_one": "1 identidad",
"count_other": "{{count}} identidades"
},
"manage": "Administrar Identidades",
"sub_addressing": {
"label": "Sub-Direccionamiento",
"description": "Use etiquetas como usuario+etiqueta@dominio.com para organizar el correo entrante",
"learn_more": "Más Información"
}
},
"advanced": {
"title": "Avanzado",
"description": "Opciones avanzadas y configuración de desarrollador",
"debug_mode": {
"label": "Modo de Depuración",
"description": "Habilitar registro detallado para solución de problemas"
},
"keyboard_shortcuts": {
"label": "Atajos de Teclado",
"description": "Ver atajos de teclado disponibles",
"button": "Ver Atajos"
},
"reset_settings": {
"label": "Restablecer Configuración",
"description": "Restaurar toda la configuración a los valores predeterminados",
"button": "Restablecer a Predeterminados"
},
"export_settings": {
"label": "Exportar Configuración",
"description": "Descargar su configuración como JSON",
"button": "Exportar"
},
"import_settings": {
"label": "Importar Configuración",
"description": "Cargar configuración desde archivo JSON",
"button": "Importar"
}
}
},
"errors": {
"page_error_title": "Algo salió mal",
"page_error_description": "Encontramos un error inesperado. Por favor, inténtelo de nuevo o regrese a la página principal.",
"sidebar_error": "No se pudieron cargar los buzones",
"email_list_error": "No se pudieron cargar los correos",
"viewer_error_title": "No se puede mostrar el correo",
"viewer_error_description": "Hubo un problema al renderizar este correo. Puede contener contenido no compatible.",
"composer_error": "No se pudo cargar el editor",
"settings_error_title": "Configuración no disponible",
"settings_error_description": "No se pudo cargar la configuración. Sus preferencias pueden no guardarse.",
"try_again": "Intentar de nuevo",
"reload": "Recargar",
"reload_emails": "Recargar correos",
"reload_settings": "Recargar configuración",
"retry": "Reintentar",
"go_home": "Ir a bandeja de entrada"
},
"context_menu": {
"reply": "Responder",
"reply_all": "Responder a Todos",
"forward": "Reenviar",
"mark_read": "Marcar como Leído",
"mark_unread": "Marcar como No Leído",
"star": "Destacar",
"unstar": "Quitar Destacado",
"move_to": "Mover a...",
"archive": "Archivar",
"delete": "Eliminar",
"mark_as_spam": "Reportar spam",
"not_spam": "No es spam",
"color_tag": "Etiqueta de Color",
"remove_color": "Eliminar Color",
"items_selected": "{{count}} correos seleccionados"
},
"shortcuts": {
"title": "Atajos de Teclado",
"tip": "Presione ? en cualquier momento para mostrar esta ayuda",
"sections": {
"navigation": "Navegación",
"actions": "Acciones de Correo",
"global": "Global",
"threads": "Conversaciones"
},
"navigation": {
"next_email": "Siguiente correo",
"previous_email": "Correo anterior",
"open_email": "Abrir correo",
"close_email": "Cerrar / Deseleccionar"
},
"actions": {
"reply": "Responder",
"reply_all": "Responder a todos",
"forward": "Reenviar",
"star": "Alternar destacado",
"archive": "Archivar",
"delete": "Eliminar",
"mark_unread": "Marcar como no leído",
"mark_read": "Marcar como leído",
"toggle_spam": "Reportar spam / No es spam"
},
"global": {
"compose": "Redactar nuevo correo",
"search": "Enfocar búsqueda",
"help": "Mostrar atajos",
"refresh": "Actualizar correos",
"select_all": "Seleccionar todo"
},
"threads": {
"expand_collapse": "Expandir/contraer conversación"
}
},
"threads": {
"messages_one": "{count} mensaje",
"messages_other": "{count} mensajes",
"expand": "Expandir conversación",
"collapse": "Contraer conversación",
"loading": "Cargando conversación...",
"mark_read": "Marcar conversación como leída",
"mark_unread": "Marcar conversación como no leída",
"archive": "Archivar conversación",
"delete": "Eliminar conversación",
"star": "Destacar conversación",
"unstar": "Quitar destacado de conversación"
},
"identities": {
"modal_title": "Administrar Identidades de Envío",
"create_new": "Crear Nueva Identidad",
"edit_identity": "Editar Identidad",
"delete_confirm": "¿Eliminar esta identidad? Esto no se puede deshacer.",
"cannot_delete": "Esta identidad no se puede eliminar",
"primary_identity": "Principal",
"no_identities": "No se encontraron identidades",
"display": {
"reply_to": "Responder a:",
"bcc": "CCO:",
"signature": "Firma:",
"preview": "Vista previa:"
},
"validation_errors": {
"invalid_emails": "Correos inválidos: {emails}",
"unknown_error": "Error desconocido"
},
"form": {
"name_label": "Nombre para Mostrar",
"name_placeholder": "ej., Correo de Trabajo, Personal",
"name_required": "El nombre es obligatorio",
"email_label": "Dirección de Correo",
"email_placeholder": "su.correo@ejemplo.com",
"email_required": "El correo es obligatorio",
"email_invalid": "Por favor ingrese una dirección de correo válida",
"email_immutable": "La dirección de correo no se puede cambiar después de la creación",
"reply_to_label": "Responder a (opcional)",
"reply_to_placeholder": "diferente@correo.com",
"bcc_label": "CCO Automático (opcional)",
"bcc_placeholder": "archivo@correo.com",
"text_signature_label": "Firma de Texto",
"html_signature_label": "Firma HTML",
"save": "Guardar Identidad",
"cancel": "Cancelar",
"creating": "Creando...",
"updating": "Actualizando..."
},
"sub_address": {
"button_tooltip": "Usar sub-dirección",
"popover_title": "Agregar Etiqueta de Sub-Dirección",
"tag_input_placeholder": "Ingrese etiqueta (ej., compras)",
"preview_label": "Vista previa:",
"recent_tags": "Etiquetas Recientes",
"suggested_tags": "Sugeridas",
"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",
"validation": {
"empty": "La etiqueta no puede estar vacía",
"too_long": "La etiqueta debe tener {max} caracteres o menos",
"invalid_chars": "La etiqueta debe contener solo letras, números y guiones"
}
},
"badge": {
"sent_via": "vía",
"sub_address_tag": "Enviado usando sub-dirección: {tag}",
"identity_name": "Enviado usando identidad: {name}",
"identity_short": "vía {name}",
"subaddress_tag": "+{tag}"
}
}
}
+170 -5
View File
@@ -52,7 +52,13 @@
"expand": "Développer", "expand": "Développer",
"collapse": "Réduire", "collapse": "Réduire",
"expand_tooltip": "Développer", "expand_tooltip": "Développer",
"collapse_tooltip": "Réduire" "collapse_tooltip": "Réduire",
"mobile": {
"search": "Rechercher",
"compose": "Composer",
"go_back": "Retour"
},
"clear_search": "Effacer la recherche"
}, },
"email_list": { "email_list": {
"no_emails": "Aucun email", "no_emails": "Aucun email",
@@ -63,7 +69,19 @@
"to_recipients": "À {{count}} destinataires", "to_recipients": "À {{count}} destinataires",
"and_others": "et {{count}} autres", "and_others": "et {{count}} autres",
"draft": "Brouillon", "draft": "Brouillon",
"starred": "Favori" "starred": "Favori",
"conversations_count": "{count} sur {total} conversations",
"conversations_count_plus": "{count}+ conversations",
"conversations_count_simple": "{count} conversations",
"no_conversations": "Aucune conversation",
"loading_more": "Chargement d'autres emails...",
"no_more_emails": "Plus d'emails à charger",
"batch_actions": {
"mark_read": "Marquer comme lu",
"mark_unread": "Marquer comme non lu",
"delete": "Supprimer",
"clear_selection": "Effacer la sélection"
}
}, },
"email_viewer": { "email_viewer": {
"no_email_selected": "Aucun email sélectionné", "no_email_selected": "Aucun email sélectionné",
@@ -129,6 +147,9 @@
"minutes": "minutes" "minutes": "minutes"
}, },
"unknown_sender": "Inconnu", "unknown_sender": "Inconnu",
"recipient_me": "moi",
"recipient_and_others": "{name} et {count} autres",
"recipient_to_prefix": "À :",
"authentication": { "authentication": {
"title": "Authentification", "title": "Authentification",
"status": { "status": {
@@ -167,7 +188,35 @@
"green": "Vert", "green": "Vert",
"blue": "Bleu", "blue": "Bleu",
"purple": "Violet", "purple": "Violet",
"pink": "Rose",
"none": "Aucune" "none": "Aucune"
},
"tooltips": {
"reply": "Répondre",
"archive": "Archiver",
"delete": "Supprimer"
},
"spam": {
"button_title": "Signaler comme spam",
"not_spam_title": "Marquer comme légitime",
"toast_success": "Déplacé vers Indésirables",
"toast_batch": "{count} e-mails déplacés vers Indésirables",
"toast_undo": "Annuler",
"toast_not_spam_success": "Déplacé vers Boîte de réception",
"toast_not_spam_batch": "{count} e-mails déplacés vers Boîte de réception",
"error": "Échec du signalement spam",
"error_not_spam": "Échec de la restauration"
},
"unsubscribe_banner": {
"label": "Newsletter",
"button": "Se désabonner",
"confirm_title": "Se désabonner de cet expéditeur ?",
"confirm_button": "Confirmer",
"cancel": "Annuler",
"success_http": "Page de désabonnement ouverte dans un nouvel onglet",
"success_mailto": "Demande de désabonnement envoyée à votre client mail",
"error": "Impossible de se désabonner",
"dismiss": "Ignorer"
} }
}, },
"email_composer": { "email_composer": {
@@ -200,6 +249,12 @@
"bcc_label": "Cci :", "bcc_label": "Cci :",
"subject_label": "Objet :", "subject_label": "Objet :",
"file_size_kb": "Ko", "file_size_kb": "Ko",
"prefix": {
"forward": "Tr:",
"reply": "Re:"
},
"no_subject": "(Sans objet)",
"unknown_sender": "Inconnu",
"quote": { "quote": {
"reply_header": "Le {{date}}, {{sender}} a écrit :", "reply_header": "Le {{date}}, {{sender}} a écrit :",
"forward_header": "---------- Message transféré ----------", "forward_header": "---------- Message transféré ----------",
@@ -207,7 +262,8 @@
"date": "Date : {{date}}", "date": "Date : {{date}}",
"subject": "Objet : {{subject}}", "subject": "Objet : {{subject}}",
"to": "À : {{recipients}}" "to": "À : {{recipients}}"
} },
"remove_sub_address": "Retirer le sous-adressage"
}, },
"common": { "common": {
"loading": "Chargement...", "loading": "Chargement...",
@@ -243,7 +299,20 @@
"error_loading": "Échec du chargement des emails", "error_loading": "Échec du chargement des emails",
"new_email": "Nouvel email", "new_email": "Nouvel email",
"new_email_from": "De {sender}", "new_email_from": "De {sender}",
"click_to_view": "Cliquer pour voir" "click_to_view": "Cliquer pour voir",
"email_moved": "Email déplacé",
"emails_moved": "{count} emails déplacés",
"moved_to_mailbox": "Déplacé vers {mailbox}",
"move_failed": "Échec du déplacement",
"move_error": "Impossible de déplacer les emails vers le dossier sélectionné",
"identity_created": "Identité créée avec succès",
"identity_updated": "Identité mise à jour avec succès",
"identity_deleted": "Identité supprimée",
"identity_create_failed": "Échec de la création de l'identité: {{error}}",
"identity_update_failed": "Échec de la mise à jour de l'identité: {{error}}",
"identity_delete_failed": "Échec de la suppression de l'identité: {{error}}",
"identity_unauthorized": "Vous n'êtes pas autorisé à envoyer depuis cette adresse email",
"identity_not_found": "Identité introuvable"
}, },
"date": { "date": {
"today": "Aujourd'hui", "today": "Aujourd'hui",
@@ -264,9 +333,21 @@
"title": "Langue", "title": "Langue",
"english": "English", "english": "English",
"french": "Français", "french": "Français",
"japanese": "日本語",
"spanish": "Español",
"italian": "Italiano",
"german": "Deutsch",
"dutch": "Nederlands",
"portuguese": "Português",
"select_language": "Sélectionner la langue", "select_language": "Sélectionner la langue",
"switch_to_english": "Passer à l'anglais", "switch_to_english": "Passer à l'anglais",
"switch_to_french": "Passer au français", "switch_to_french": "Passer au français",
"switch_to_japanese": "Passer au japonais",
"switch_to_spanish": "Passer à l'espagnol",
"switch_to_italian": "Passer à l'italien",
"switch_to_german": "Passer à l'allemand",
"switch_to_dutch": "Passer au néerlandais",
"switch_to_portuguese": "Passer au portugais",
"switching": "Changement de langue..." "switching": "Changement de langue..."
}, },
"settings": { "settings": {
@@ -283,6 +364,7 @@
"composer": "Compositeur", "composer": "Compositeur",
"privacy": "Confidentialité et sécurité", "privacy": "Confidentialité et sécurité",
"account": "Compte", "account": "Compte",
"identities": "Identités",
"advanced": "Avancé" "advanced": "Avancé"
}, },
"appearance": { "appearance": {
@@ -468,6 +550,23 @@
"value": "{{time}}" "value": "{{time}}"
} }
}, },
"identities": {
"title": "Identités d'envoi",
"description": "Gérer les adresses email depuis lesquelles vous pouvez envoyer",
"identities_count": {
"label": "Vos identités",
"description": "Adresses email configurées pour l'envoi",
"count_zero": "Aucune identité",
"count_one": "1 identité",
"count_other": "{{count}} identités"
},
"manage": "Gérer les identités",
"sub_addressing": {
"label": "Sous-adressage",
"description": "Utilisez des tags comme utilisateur+tag@domaine.com pour organiser votre courrier",
"learn_more": "En savoir plus"
}
},
"advanced": { "advanced": {
"title": "Avancé", "title": "Avancé",
"description": "Options avancées et paramètres développeur", "description": "Options avancées et paramètres développeur",
@@ -525,6 +624,8 @@
"move_to": "Déplacer vers...", "move_to": "Déplacer vers...",
"archive": "Archiver", "archive": "Archiver",
"delete": "Supprimer", "delete": "Supprimer",
"mark_as_spam": "Signaler comme spam",
"not_spam": "Pas un spam",
"color_tag": "Étiquette de couleur", "color_tag": "Étiquette de couleur",
"remove_color": "Supprimer la couleur", "remove_color": "Supprimer la couleur",
"items_selected": "{{count}} emails sélectionnés" "items_selected": "{{count}} emails sélectionnés"
@@ -552,7 +653,8 @@
"archive": "Archiver", "archive": "Archiver",
"delete": "Supprimer", "delete": "Supprimer",
"mark_unread": "Marquer comme non lu", "mark_unread": "Marquer comme non lu",
"mark_read": "Marquer comme lu" "mark_read": "Marquer comme lu",
"toggle_spam": "Signaler / Pas un spam"
}, },
"global": { "global": {
"compose": "Composer un email", "compose": "Composer un email",
@@ -577,5 +679,68 @@
"delete": "Supprimer la conversation", "delete": "Supprimer la conversation",
"star": "Marquer la conversation comme favorite", "star": "Marquer la conversation comme favorite",
"unstar": "Retirer des favoris" "unstar": "Retirer des favoris"
},
"identities": {
"modal_title": "Gérer les identités d'envoi",
"create_new": "Créer une nouvelle identité",
"edit_identity": "Modifier l'identité",
"delete_confirm": "Supprimer cette identité ? Cette action est irréversible.",
"cannot_delete": "Cette identité ne peut pas être supprimée",
"primary_identity": "Principale",
"no_identities": "Aucune identité trouvée",
"display": {
"reply_to": "Répondre à :",
"bcc": "CCI :",
"signature": "Signature :",
"preview": "Aperçu :"
},
"validation_errors": {
"invalid_emails": "Emails invalides : {emails}",
"unknown_error": "Erreur inconnue"
},
"form": {
"name_label": "Nom d'affichage",
"name_placeholder": "ex: Email professionnel, Personnel",
"name_required": "Le nom est requis",
"email_label": "Adresse email",
"email_placeholder": "votre.email@exemple.com",
"email_required": "L'email est requis",
"email_invalid": "Veuillez entrer une adresse email valide",
"email_immutable": "L'adresse email ne peut pas être modifiée après création",
"reply_to_label": "Répondre à (optionnel)",
"reply_to_placeholder": "autre@email.com",
"bcc_label": "Copie cachée automatique (optionnel)",
"bcc_placeholder": "archive@email.com",
"text_signature_label": "Signature texte",
"html_signature_label": "Signature HTML",
"save": "Enregistrer l'identité",
"cancel": "Annuler",
"creating": "Création...",
"updating": "Mise à jour..."
},
"sub_address": {
"button_tooltip": "Utiliser le sous-adressage",
"popover_title": "Ajouter un tag de sous-adresse",
"tag_input_placeholder": "Entrez un tag (ex: shopping)",
"preview_label": "Aperçu:",
"recent_tags": "Tags récents",
"suggested_tags": "Suggérés",
"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",
"validation": {
"empty": "Le tag ne peut pas être vide",
"too_long": "Le tag doit faire {max} caractères ou moins",
"invalid_chars": "Le tag doit contenir uniquement des lettres, chiffres et tirets"
}
},
"badge": {
"sent_via": "via",
"sub_address_tag": "Envoyé avec le sous-adresse: {tag}",
"identity_name": "Envoyé avec l'identité: {name}",
"identity_short": "via {name}",
"subaddress_tag": "+{tag}"
}
} }
} }
+746
View File
@@ -0,0 +1,746 @@
{
"login": {
"title": "Webmail",
"username_label": "Email",
"username_placeholder": "utente@esempio.com",
"password_label": "Password",
"password_placeholder": "Inserisci la tua password",
"sign_in": "Accedi",
"signing_in": "Accesso in corso...",
"loading": "Caricamento...",
"error": {
"invalid_credentials": "Email o password non valida",
"connection_failed": "Impossibile connettersi al server",
"generic": "Si è verificato un errore. Riprova."
},
"config_error": {
"title": "Errore di configurazione",
"fetch_failed": "Impossibile caricare la configurazione dell'applicazione. Riprovare più tardi.",
"server_not_configured": "Il server di posta non è stato configurato. Contattare l'amministratore."
},
"remove_from_history": "Rimuovi dalla cronologia"
},
"sidebar": {
"close": "Chiudi",
"compose": "Scrivi",
"search_placeholder": "Cerca nella posta...",
"storage": "Spazio di archiviazione",
"sign_out": "Esci",
"settings": "Impostazioni",
"loading_mailboxes": "Caricamento caselle di posta...",
"push_connected": "Aggiornamenti in tempo reale attivi",
"push_disconnected": "Aggiornamenti in tempo reale non attivi",
"theme": {
"light": "Tema chiaro",
"dark": "Tema scuro",
"system": "Tema di sistema"
},
"language": {
"title": "Lingua"
},
"mailboxes": {
"inbox": "Posta in arrivo",
"sent": "Inviati",
"drafts": "Bozze",
"trash": "Cestino",
"archive": "Archivio",
"starred": "Speciali",
"all_mail": "Tutta la posta",
"spam": "Spam",
"important": "Importanti"
},
"expand": "Espandi",
"collapse": "Comprimi",
"expand_tooltip": "Espandi",
"collapse_tooltip": "Comprimi",
"mobile": {
"search": "Cerca",
"compose": "Scrivi",
"go_back": "Indietro"
},
"clear_search": "Cancella ricerca"
},
"email_list": {
"no_emails": "Nessun messaggio",
"no_emails_description": "Inizia scrivendo un nuovo messaggio",
"loading": "Caricamento messaggi...",
"unread": "non letto",
"to_me": "A me",
"to_recipients": "A {{count}} destinatari",
"and_others": "e altri {{count}}",
"draft": "Bozza",
"starred": "Speciale",
"conversations_count": "{count} di {total} conversazioni",
"conversations_count_plus": "{count}+ conversazioni",
"conversations_count_simple": "{count} conversazioni",
"no_conversations": "Nessuna conversazione",
"loading_more": "Caricamento altri messaggi...",
"no_more_emails": "Nessun altro messaggio da caricare",
"batch_actions": {
"mark_read": "Segna come letto",
"mark_unread": "Segna come non letto",
"delete": "Elimina",
"clear_selection": "Cancella selezione"
}
},
"email_viewer": {
"no_email_selected": "Nessun messaggio selezionato",
"no_email_description": "Seleziona un messaggio dall'elenco per visualizzarlo qui",
"no_conversation_selected": "Nessuna conversazione selezionata",
"no_conversation_description": "Scegli una conversazione dall'elenco per leggerla qui",
"no_subject": "(Nessun oggetto)",
"loading_email": "Caricamento messaggio...",
"loading": "Caricamento...",
"reply": "Rispondi",
"reply_all": "Rispondi a tutti",
"forward": "Inoltra",
"delete": "Elimina",
"archive": "Archivia",
"star": "Aggiungi stella",
"unstar": "Rimuovi stella",
"mark_unread": "Segna come non letto",
"mark_read": "Segna come letto",
"print": "Stampa",
"view_source": "Visualizza sorgente",
"email_source": "Sorgente del messaggio",
"copy_source": "Copia negli appunti",
"source_copied": "Sorgente copiata negli appunti",
"attachments": "Allegati",
"important": "Importante",
"download": "Scarica",
"from": "Da",
"to": "A",
"cc": "CC",
"bcc": "CCN",
"date": "Data",
"subject": "Oggetto",
"show_details": "Mostra dettagli",
"hide_details": "Nascondi dettagli",
"external_content_warning": "Le immagini e i contenuti esterni sono stati bloccati",
"load_external_content": "Carica immagini",
"trust_sender": "Considera sempre attendibile questo mittente",
"back_to_list": "Torna all'elenco",
"message_details": "Dettagli del messaggio",
"more_reply_options": "Più opzioni di risposta",
"set_color": "Imposta colore",
"more_actions": "Altre azioni",
"remove_color": "Rimuovi colore",
"more_count": "+{count} altri",
"characters_count": "{count} caratteri",
"quick_reply_placeholder": "Scrivi una risposta veloce...",
"more_options": "Più opzioni",
"sending": "Invio in corso...",
"security_authentication": "Sicurezza e autenticazione",
"technical_details": "Dettagli tecnici",
"message_id_label": "ID messaggio:",
"reply_to_label": "Rispondi a:",
"delivery_time_label": "Orario di consegna:",
"conversation_part_label": "Parte della conversazione:",
"previous_messages": "{count} messaggio precedente",
"previous_messages_plural": "{count} messaggi precedenti",
"time": {
"day": "giorno",
"days": "giorni",
"hour": "ora",
"hours": "ore",
"minute": "minuto",
"minutes": "minuti"
},
"unknown_sender": "Sconosciuto",
"recipient_me": "io",
"recipient_and_others": "{name} e altri {count}",
"recipient_to_prefix": "A:",
"authentication": {
"title": "Autenticazione",
"status": {
"verified": "Verificato",
"warning": "Attenzione",
"none": "Non autenticato"
},
"spf": {
"pass": "SPF superato",
"fail": "SPF fallito",
"none": "Nessun SPF"
},
"dkim": {
"pass": "DKIM valido",
"fail": "DKIM non valido",
"none": "Nessun DKIM"
},
"dmarc": {
"pass": "DMARC superato",
"fail": "DMARC fallito",
"none": "Nessun DMARC"
},
"spam_score": "Punteggio spam"
},
"headers": {
"routing": "Instradamento",
"received": "Ricevuto",
"message_id": "ID messaggio",
"list_info": "Informazioni lista"
},
"color_tag": {
"title": "Etichetta colore",
"red": "Rosso",
"orange": "Arancione",
"yellow": "Giallo",
"green": "Verde",
"blue": "Blu",
"purple": "Viola",
"pink": "Rosa",
"none": "Nessuno"
},
"tooltips": {
"reply": "Rispondi",
"archive": "Archivia",
"delete": "Elimina"
},
"spam": {
"button_title": "Segnala come spam",
"not_spam_title": "Segna come legittimo",
"toast_success": "Spostato in Posta indesiderata",
"toast_batch": "{count} messaggi spostati in Posta indesiderata",
"toast_undo": "Annulla",
"toast_not_spam_success": "Spostato in Posta in arrivo",
"toast_not_spam_batch": "{count} messaggi spostati in Posta in arrivo",
"error": "Impossibile segnalare come spam",
"error_not_spam": "Impossibile ripristinare il messaggio"
},
"unsubscribe_banner": {
"label": "Newsletter",
"button": "Annulla iscrizione",
"confirm_title": "Annullare l'iscrizione da questo mittente?",
"confirm_button": "Conferma",
"cancel": "Annulla",
"success_http": "Pagina di annullamento iscrizione aperta in una nuova scheda",
"success_mailto": "Richiesta di annullamento iscrizione inviata al tuo client email",
"error": "Impossibile annullare l'iscrizione",
"dismiss": "Ignora"
}
},
"email_composer": {
"new_message": "Nuovo messaggio",
"reply": "Rispondi",
"reply_all": "Rispondi a tutti",
"forward": "Inoltra",
"reply_to": "Rispondi",
"reply_all_to": "Rispondi a tutti",
"forward_message": "Inoltra",
"from": "Da",
"to": "A",
"cc": "CC",
"bcc": "CCN",
"subject": "Oggetto",
"body_placeholder": "Scrivi il tuo messaggio...",
"send": "Invia",
"cancel": "Annulla",
"attach": "Allega",
"discard": "Scarta",
"discard_draft_confirm": "Ci sono modifiche non salvate. Vuoi scartare questa bozza?",
"saving": "Salvataggio...",
"draft_saved": "Bozza salvata",
"save_failed": "Salvataggio non riuscito",
"to_placeholder": "Indirizzi email dei destinatari (separati da virgola)",
"cc_placeholder": "Destinatari in copia (separati da virgola)",
"bcc_placeholder": "Destinatari in copia nascosta (separati da virgola)",
"subject_placeholder": "Oggetto",
"cc_label": "Cc:",
"bcc_label": "Ccn:",
"subject_label": "Oggetto:",
"file_size_kb": "KB",
"prefix": {
"forward": "Inol:",
"reply": "Re:"
},
"no_subject": "(Nessun oggetto)",
"unknown_sender": "Sconosciuto",
"quote": {
"reply_header": "Il {{date}}, {{sender}} ha scritto:",
"forward_header": "---------- Messaggio inoltrato ----------",
"from": "Da: {{sender}}",
"date": "Data: {{date}}",
"subject": "Oggetto: {{subject}}",
"to": "A: {{recipients}}"
},
"remove_sub_address": "Rimuovi sotto-indirizzo"
},
"common": {
"loading": "Caricamento...",
"error": "Errore",
"success": "Successo",
"cancel": "Annulla",
"save": "Salva",
"delete": "Elimina",
"edit": "Modifica",
"close": "Chiudi",
"search": "Cerca",
"refresh": "Aggiorna",
"settings": "Impostazioni",
"help": "Aiuto",
"logout": "Esci",
"yes": "Sì",
"no": "No",
"unknown": "Sconosciuto",
"app_title": "Webmail"
},
"notifications": {
"email_sent": "Messaggio inviato con successo",
"email_deleted": "Messaggio eliminato",
"email_archived": "Messaggio archiviato",
"email_starred": "Stella aggiunta al messaggio",
"email_unstarred": "Stella rimossa dal messaggio",
"email_marked_read": "Messaggio segnato come letto",
"email_marked_unread": "Messaggio segnato come non letto",
"copied_to_clipboard": "Copiato negli appunti",
"source_copied": "Sorgente copiata negli appunti",
"error_sending": "Impossibile inviare il messaggio",
"error_deleting": "Impossibile eliminare il messaggio",
"error_loading": "Impossibile caricare i messaggi",
"new_email": "Nuovo messaggio",
"new_email_from": "Da {sender}",
"click_to_view": "Clicca per visualizzare",
"email_moved": "Messaggio spostato",
"emails_moved": "{count} messaggi spostati",
"moved_to_mailbox": "Spostato in {mailbox}",
"move_failed": "Spostamento non riuscito",
"move_error": "Impossibile spostare i messaggi nella cartella selezionata",
"identity_created": "Identità creata con successo",
"identity_updated": "Identità aggiornata con successo",
"identity_deleted": "Identità eliminata",
"identity_create_failed": "Impossibile creare l'identità: {{error}}",
"identity_update_failed": "Impossibile aggiornare l'identità: {{error}}",
"identity_delete_failed": "Impossibile eliminare l'identità: {{error}}",
"identity_unauthorized": "Non sei autorizzato a inviare da questo indirizzo email",
"identity_not_found": "Identità non trovata"
},
"date": {
"today": "Oggi",
"yesterday": "Ieri",
"this_week": "Questa settimana",
"last_week": "Settimana scorsa",
"this_month": "Questo mese",
"older": "Meno recenti",
"just_now": "Proprio ora",
"minutes_ago": "{{count}} minuto fa",
"minutes_ago_plural": "{{count}} minuti fa",
"hours_ago": "{{count}} ora fa",
"hours_ago_plural": "{{count}} ore fa",
"days_ago": "{{count}} giorno fa",
"days_ago_plural": "{{count}} giorni fa"
},
"language": {
"title": "Lingua",
"english": "English",
"french": "Français",
"japanese": "日本語",
"spanish": "Español",
"italian": "Italiano",
"german": "Deutsch",
"dutch": "Nederlands",
"portuguese": "Português",
"select_language": "Seleziona lingua",
"switch_to_english": "Passa all'inglese",
"switch_to_french": "Passa al francese",
"switch_to_japanese": "Passa al giapponese",
"switch_to_spanish": "Passa allo spagnolo",
"switch_to_italian": "Passa all'italiano",
"switch_to_german": "Passa al tedesco",
"switch_to_dutch": "Passa all'olandese",
"switch_to_portuguese": "Passa al portoghese",
"switching": "Cambio lingua in corso..."
},
"settings": {
"title": "Impostazioni",
"back_to_mail": "Torna alla posta",
"save_success": "Impostazioni salvate con successo",
"import_success": "Impostazioni importate con successo",
"import_error": "Impossibile importare le impostazioni",
"reset_confirm": "Sei sicuro di voler ripristinare tutte le impostazioni ai valori predefiniti?",
"tabs": {
"appearance": "Aspetto",
"language": "Lingua e regione",
"email": "Comportamento email",
"composer": "Editor",
"privacy": "Privacy e sicurezza",
"account": "Account",
"identities": "Identità",
"advanced": "Avanzate"
},
"appearance": {
"title": "Aspetto",
"description": "Personalizza l'aspetto della tua webmail",
"theme": {
"label": "Tema",
"description": "Scegli la tua combinazione di colori preferita",
"light": "Chiaro",
"dark": "Scuro",
"system": "Sistema"
},
"language": {
"label": "Lingua",
"description": "Scegli la tua lingua preferita"
},
"font_size": {
"label": "Dimensione carattere",
"description": "Regola la dimensione del testo per una migliore leggibilità",
"small": "Piccolo",
"medium": "Medio",
"large": "Grande"
},
"list_density": {
"label": "Densità elenco",
"description": "Controlla la spaziatura negli elenchi email",
"compact": "Compatto",
"regular": "Normale",
"comfortable": "Comodo"
},
"animations": {
"label": "Abilita animazioni",
"description": "Mostra transizioni ed effetti fluidi"
}
},
"language_region": {
"title": "Lingua e regione",
"description": "Configura le preferenze di lingua e regionali",
"language": {
"label": "Lingua",
"description": "Scegli la tua lingua preferita",
"english": "English",
"french": "Français"
},
"date_format": {
"label": "Formato data",
"description": "Come devono essere visualizzate le date",
"regional": "Regionale",
"iso": "ISO 8601",
"custom": "Personalizzato"
},
"time_format": {
"label": "Formato ora",
"description": "Scegli tra formato 12 o 24 ore",
"12h": "12 ore",
"24h": "24 ore"
},
"first_day": {
"label": "Primo giorno della settimana",
"description": "Inizia la settimana di domenica o lunedì",
"sunday": "Domenica",
"monday": "Lunedì"
}
},
"email_behavior": {
"title": "Comportamento email",
"description": "Configura come vengono gestiti i messaggi",
"mark_read": {
"label": "Segna come letto",
"description": "Quando segnare i messaggi come letti all'apertura",
"instant": "Istantaneamente",
"delay_3s": "Dopo 3 secondi",
"delay_5s": "Dopo 5 secondi",
"never": "Mai"
},
"delete_action": {
"label": "Azione di eliminazione",
"description": "Cosa accade quando elimini un messaggio",
"trash": "Sposta nel cestino",
"permanent": "Elimina definitivamente"
},
"show_preview": {
"label": "Mostra anteprima testo",
"description": "Visualizza l'anteprima del messaggio nell'elenco"
},
"emails_per_page": {
"label": "Messaggi per pagina",
"description": "Numero di messaggi da caricare alla volta",
"25": "25 messaggi",
"50": "50 messaggi",
"100": "100 messaggi"
},
"external_content": {
"label": "Contenuti esterni",
"description": "Come gestire immagini e contenuti esterni",
"ask": "Chiedi sempre",
"block": "Blocca sempre",
"allow": "Consenti sempre"
},
"trusted_senders": {
"label": "Mittenti attendibili",
"description": "Gestisci i mittenti le cui immagini si caricano automaticamente",
"count_zero": "Nessuno",
"count_one": "1 mittente",
"count_other": "{count} mittenti",
"modal_title": "Mittenti attendibili",
"empty_title": "Nessun mittente attendibile ancora",
"empty_description": "Durante la visualizzazione di un messaggio con immagini bloccate, clicca \"Considera sempre attendibile questo mittente\" per aggiungerlo qui.",
"add_manually": "Aggiungi mittente manualmente",
"add_button": "Aggiungi",
"add_placeholder": "Inserisci indirizzo email",
"search_placeholder": "Cerca mittenti...",
"no_results": "Nessun mittente corrisponde alla tua ricerca",
"remove": "Rimuovi",
"close": "Chiudi",
"invalid_email": "Inserisci un indirizzo email valido",
"already_added": "Questo mittente è già attendibile"
}
},
"composer": {
"title": "Editor",
"description": "Configura le impostazioni di composizione email",
"autosave": {
"label": "Intervallo di salvataggio automatico",
"description": "Con quale frequenza salvare automaticamente le bozze",
"30s": "Ogni 30 secondi",
"1m": "Ogni minuto",
"2m": "Ogni 2 minuti",
"5m": "Ogni 5 minuti"
},
"send_confirmation": {
"label": "Conferma invio",
"description": "Chiedi conferma prima di inviare messaggi"
},
"default_reply": {
"label": "Modalità risposta predefinita",
"description": "Azione predefinita quando si fa clic su rispondi",
"reply": "Rispondi",
"reply_all": "Rispondi a tutti"
}
},
"privacy": {
"title": "Privacy e sicurezza",
"description": "Gestisci le tue impostazioni di privacy e sicurezza",
"external_images": {
"label": "Blocca immagini esterne",
"description": "Impedisci il tracciamento attraverso immagini esterne"
},
"session_timeout": {
"label": "Timeout sessione",
"description": "Disconnetti automaticamente dopo inattività",
"never": "Mai",
"30m": "30 minuti",
"1h": "1 ora",
"4h": "4 ore"
},
"clear_cache": {
"label": "Cancella cache",
"description": "Rimuovi dati memorizzati nella cache e file temporanei",
"button": "Cancella cache",
"confirm": "Sei sicuro di voler cancellare la cache?",
"success": "Cache cancellata con successo"
}
},
"account": {
"title": "Account",
"description": "Visualizza le informazioni del tuo account",
"email": {
"label": "Indirizzo email",
"value": "{{email}}"
},
"server": {
"label": "Server JMAP",
"value": "{{server}}"
},
"storage": {
"label": "Utilizzo spazio",
"used": "{{used}} di {{total}} utilizzati",
"percentage": "{{percent}}% utilizzato"
},
"last_sync": {
"label": "Ultima sincronizzazione",
"value": "{{time}}"
}
},
"identities": {
"title": "Identità di invio",
"description": "Gestisci gli indirizzi email da cui puoi inviare",
"identities_count": {
"label": "Le tue identità",
"description": "Indirizzi email configurati per l'invio",
"count_zero": "Nessuna identità",
"count_one": "1 identità",
"count_other": "{{count}} identità"
},
"manage": "Gestisci identità",
"sub_addressing": {
"label": "Sotto-indirizzamento",
"description": "Usa tag come utente+tag@dominio.com per organizzare la posta in arrivo",
"learn_more": "Scopri di più"
}
},
"advanced": {
"title": "Avanzate",
"description": "Opzioni avanzate e impostazioni per sviluppatori",
"debug_mode": {
"label": "Modalità debug",
"description": "Abilita registrazione dettagliata per la risoluzione dei problemi"
},
"keyboard_shortcuts": {
"label": "Scorciatoie da tastiera",
"description": "Visualizza le scorciatoie da tastiera disponibili",
"button": "Visualizza scorciatoie"
},
"reset_settings": {
"label": "Ripristina impostazioni",
"description": "Ripristina tutte le impostazioni ai valori predefiniti",
"button": "Ripristina ai valori predefiniti"
},
"export_settings": {
"label": "Esporta impostazioni",
"description": "Scarica le tue impostazioni come JSON",
"button": "Esporta"
},
"import_settings": {
"label": "Importa impostazioni",
"description": "Carica impostazioni da file JSON",
"button": "Importa"
}
}
},
"errors": {
"page_error_title": "Qualcosa è andato storto",
"page_error_description": "Si è verificato un errore imprevisto. Riprova o torna alla pagina principale.",
"sidebar_error": "Impossibile caricare le caselle di posta",
"email_list_error": "Impossibile caricare i messaggi",
"viewer_error_title": "Impossibile visualizzare il messaggio",
"viewer_error_description": "Si è verificato un problema nella visualizzazione di questo messaggio. Potrebbe contenere contenuti non supportati.",
"composer_error": "Impossibile caricare l'editor",
"settings_error_title": "Impostazioni non disponibili",
"settings_error_description": "Impossibile caricare le impostazioni. Le tue preferenze potrebbero non essere salvate.",
"try_again": "Riprova",
"reload": "Ricarica",
"reload_emails": "Ricarica messaggi",
"reload_settings": "Ricarica impostazioni",
"retry": "Riprova",
"go_home": "Vai alla posta in arrivo"
},
"context_menu": {
"reply": "Rispondi",
"reply_all": "Rispondi a tutti",
"forward": "Inoltra",
"mark_read": "Segna come letto",
"mark_unread": "Segna come non letto",
"star": "Aggiungi stella",
"unstar": "Rimuovi stella",
"move_to": "Sposta in...",
"archive": "Archivia",
"delete": "Elimina",
"mark_as_spam": "Segnala come spam",
"not_spam": "Non spam",
"color_tag": "Etichetta colore",
"remove_color": "Rimuovi colore",
"items_selected": "{{count}} messaggi selezionati"
},
"shortcuts": {
"title": "Scorciatoie da tastiera",
"tip": "Premi ? in qualsiasi momento per mostrare questo aiuto",
"sections": {
"navigation": "Navigazione",
"actions": "Azioni sui messaggi",
"global": "Globali",
"threads": "Conversazioni"
},
"navigation": {
"next_email": "Messaggio successivo",
"previous_email": "Messaggio precedente",
"open_email": "Apri messaggio",
"close_email": "Chiudi / Deseleziona"
},
"actions": {
"reply": "Rispondi",
"reply_all": "Rispondi a tutti",
"forward": "Inoltra",
"star": "Attiva/disattiva stella",
"archive": "Archivia",
"delete": "Elimina",
"mark_unread": "Segna come non letto",
"mark_read": "Segna come letto",
"toggle_spam": "Segnala spam / Non spam"
},
"global": {
"compose": "Scrivi nuovo messaggio",
"search": "Cerca",
"help": "Mostra scorciatoie",
"refresh": "Aggiorna messaggi",
"select_all": "Seleziona tutto"
},
"threads": {
"expand_collapse": "Espandi/comprimi conversazione"
}
},
"threads": {
"messages_one": "{count} messaggio",
"messages_other": "{count} messaggi",
"expand": "Espandi conversazione",
"collapse": "Comprimi conversazione",
"loading": "Caricamento conversazione...",
"mark_read": "Segna conversazione come letta",
"mark_unread": "Segna conversazione come non letta",
"archive": "Archivia conversazione",
"delete": "Elimina conversazione",
"star": "Aggiungi stella alla conversazione",
"unstar": "Rimuovi stella dalla conversazione"
},
"identities": {
"modal_title": "Gestisci identità di invio",
"create_new": "Crea nuova identità",
"edit_identity": "Modifica identità",
"delete_confirm": "Eliminare questa identità? Questa azione non può essere annullata.",
"cannot_delete": "Questa identità non può essere eliminata",
"primary_identity": "Principale",
"no_identities": "Nessuna identità trovata",
"display": {
"reply_to": "Rispondi a:",
"bcc": "CCN:",
"signature": "Firma:",
"preview": "Anteprima:"
},
"validation_errors": {
"invalid_emails": "Email non valide: {emails}",
"unknown_error": "Errore sconosciuto"
},
"form": {
"name_label": "Nome visualizzato",
"name_placeholder": "es. Email lavoro, Personale",
"name_required": "Il nome è obbligatorio",
"email_label": "Indirizzo email",
"email_placeholder": "tua.email@esempio.com",
"email_required": "L'email è obbligatoria",
"email_invalid": "Inserisci un indirizzo email valido",
"email_immutable": "L'indirizzo email non può essere modificato dopo la creazione",
"reply_to_label": "Rispondi a (facoltativo)",
"reply_to_placeholder": "diversa@email.com",
"bcc_label": "CCN automatica (facoltativo)",
"bcc_placeholder": "archivio@email.com",
"text_signature_label": "Firma di testo",
"html_signature_label": "Firma HTML",
"save": "Salva identità",
"cancel": "Annulla",
"creating": "Creazione...",
"updating": "Aggiornamento..."
},
"sub_address": {
"button_tooltip": "Usa sotto-indirizzo",
"popover_title": "Aggiungi tag sotto-indirizzo",
"tag_input_placeholder": "Inserisci tag (es. shopping)",
"preview_label": "Anteprima:",
"recent_tags": "Tag recenti",
"suggested_tags": "Suggeriti",
"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",
"validation": {
"empty": "Il tag non può essere vuoto",
"too_long": "Il tag deve essere di massimo {max} caratteri",
"invalid_chars": "Il tag deve contenere solo lettere, numeri e trattini"
}
},
"badge": {
"sent_via": "tramite",
"sub_address_tag": "Inviato usando sotto-indirizzo: {tag}",
"identity_name": "Inviato usando identità: {name}",
"identity_short": "tramite {name}",
"subaddress_tag": "+{tag}"
}
}
}
+746
View File
@@ -0,0 +1,746 @@
{
"login": {
"title": "ウェブメール",
"username_label": "メールアドレス",
"username_placeholder": "user@example.com",
"password_label": "パスワード",
"password_placeholder": "パスワードを入力",
"sign_in": "サインイン",
"signing_in": "サインイン中...",
"loading": "読み込み中...",
"error": {
"invalid_credentials": "メールアドレスまたはパスワードが無効です",
"connection_failed": "サーバーへの接続に失敗しました",
"generic": "エラーが発生しました。もう一度お試しください。"
},
"config_error": {
"title": "設定エラー",
"fetch_failed": "アプリケーション設定を読み込めません。後でもう一度お試しください。",
"server_not_configured": "メールサーバーが設定されていません。管理者にお問い合わせください。"
},
"remove_from_history": "履歴から削除"
},
"sidebar": {
"close": "閉じる",
"compose": "作成",
"search_placeholder": "メールを検索...",
"storage": "ストレージ",
"sign_out": "サインアウト",
"settings": "設定",
"loading_mailboxes": "メールボックスを読み込み中...",
"push_connected": "リアルタイム更新が有効",
"push_disconnected": "リアルタイム更新が無効",
"theme": {
"light": "ライトモード",
"dark": "ダークモード",
"system": "システムテーマ"
},
"language": {
"title": "言語"
},
"mailboxes": {
"inbox": "受信トレイ",
"sent": "送信済み",
"drafts": "下書き",
"trash": "ゴミ箱",
"archive": "アーカイブ",
"starred": "スター付き",
"all_mail": "すべてのメール",
"spam": "迷惑メール",
"important": "重要"
},
"expand": "展開",
"collapse": "折りたたむ",
"expand_tooltip": "展開",
"collapse_tooltip": "折りたたむ",
"mobile": {
"search": "検索",
"compose": "作成",
"go_back": "戻る"
},
"clear_search": "検索をクリア"
},
"email_list": {
"no_emails": "メールがありません",
"no_emails_description": "新しいメールを作成してください",
"loading": "メールを読み込み中...",
"unread": "未読",
"to_me": "宛先: 自分",
"to_recipients": "{{count}}人の宛先",
"and_others": "他{{count}}人",
"draft": "下書き",
"starred": "スター付き",
"conversations_count": "{total}件中{count}件の会話",
"conversations_count_plus": "{count}件以上の会話",
"conversations_count_simple": "{count}件の会話",
"no_conversations": "会話がありません",
"loading_more": "さらにメールを読み込み中...",
"no_more_emails": "読み込むメールがありません",
"batch_actions": {
"mark_read": "既読にする",
"mark_unread": "未読にする",
"delete": "削除",
"clear_selection": "選択を解除"
}
},
"email_viewer": {
"no_email_selected": "メールが選択されていません",
"no_email_description": "リストからメールを選択して表示してください",
"no_conversation_selected": "会話が選択されていません",
"no_conversation_description": "リストから会話を選択して読んでください",
"no_subject": "(件名なし)",
"loading_email": "メールを読み込み中...",
"loading": "読み込み中...",
"reply": "返信",
"reply_all": "全員に返信",
"forward": "転送",
"delete": "削除",
"archive": "アーカイブ",
"star": "スターを付ける",
"unstar": "スターを外す",
"mark_unread": "未読にする",
"mark_read": "既読にする",
"print": "印刷",
"view_source": "ソースを表示",
"email_source": "メールソース",
"copy_source": "クリップボードにコピー",
"source_copied": "ソースをクリップボードにコピーしました",
"attachments": "添付ファイル",
"important": "重要",
"download": "ダウンロード",
"from": "送信者",
"to": "宛先",
"cc": "CC",
"bcc": "BCC",
"date": "日付",
"subject": "件名",
"show_details": "詳細を表示",
"hide_details": "詳細を非表示",
"external_content_warning": "画像と外部コンテンツがブロックされました",
"load_external_content": "画像を読み込む",
"trust_sender": "常にこの送信者を信頼する",
"back_to_list": "リストに戻る",
"message_details": "メッセージの詳細",
"more_reply_options": "その他の返信オプション",
"set_color": "色を設定",
"more_actions": "その他の操作",
"remove_color": "色を削除",
"more_count": "他{count}件",
"characters_count": "{count}文字",
"quick_reply_placeholder": "クイック返信を入力...",
"more_options": "その他のオプション",
"sending": "送信中...",
"security_authentication": "セキュリティと認証",
"technical_details": "技術的な詳細",
"message_id_label": "メッセージID:",
"reply_to_label": "返信先:",
"delivery_time_label": "配信時刻:",
"conversation_part_label": "会話の一部:",
"previous_messages": "{count}件の以前のメッセージ",
"previous_messages_plural": "{count}件の以前のメッセージ",
"time": {
"day": "日",
"days": "日",
"hour": "時間",
"hours": "時間",
"minute": "分",
"minutes": "分"
},
"unknown_sender": "不明",
"recipient_me": "自分",
"recipient_and_others": "{name}と他{count}人",
"recipient_to_prefix": "宛先:",
"authentication": {
"title": "認証",
"status": {
"verified": "確認済み",
"warning": "警告",
"none": "未認証"
},
"spf": {
"pass": "SPF合格",
"fail": "SPF不合格",
"none": "SPFなし"
},
"dkim": {
"pass": "DKIM有効",
"fail": "DKIM無効",
"none": "DKIMなし"
},
"dmarc": {
"pass": "DMARC合格",
"fail": "DMARC不合格",
"none": "DMARCなし"
},
"spam_score": "スパムスコア"
},
"headers": {
"routing": "ルーティング",
"received": "受信",
"message_id": "メッセージID",
"list_info": "リスト情報"
},
"color_tag": {
"title": "カラータグ",
"red": "赤",
"orange": "オレンジ",
"yellow": "黄色",
"green": "緑",
"blue": "青",
"purple": "紫",
"pink": "ピンク",
"none": "なし"
},
"tooltips": {
"reply": "返信",
"archive": "アーカイブ",
"delete": "削除"
},
"spam": {
"button_title": "迷惑メールを報告",
"not_spam_title": "正当なメールとしてマーク",
"toast_success": "迷惑メールに移動しました",
"toast_batch": "{count}件のメールを迷惑メールに移動しました",
"toast_undo": "元に戻す",
"toast_not_spam_success": "受信トレイに移動しました",
"toast_not_spam_batch": "{count}件のメールを受信トレイに移動しました",
"error": "迷惑メールの報告に失敗しました",
"error_not_spam": "メールの復元に失敗しました"
},
"unsubscribe_banner": {
"label": "ニュースレター",
"button": "購読解除",
"confirm_title": "この送信者の購読を解除しますか?",
"confirm_button": "確認",
"cancel": "キャンセル",
"success_http": "購読解除ページを新しいタブで開きました",
"success_mailto": "購読解除リクエストをメールクライアントに送信しました",
"error": "購読解除できませんでした",
"dismiss": "閉じる"
}
},
"email_composer": {
"new_message": "新規メッセージ",
"reply": "返信",
"reply_all": "全員に返信",
"forward": "転送",
"reply_to": "返信",
"reply_all_to": "全員に返信",
"forward_message": "転送",
"from": "送信元",
"to": "宛先",
"cc": "CC",
"bcc": "BCC",
"subject": "件名",
"body_placeholder": "メッセージを入力...",
"send": "送信",
"cancel": "キャンセル",
"attach": "添付",
"discard": "破棄",
"discard_draft_confirm": "未保存の変更があります。この下書きを破棄しますか?",
"saving": "保存中...",
"draft_saved": "下書きを保存しました",
"save_failed": "保存に失敗しました",
"to_placeholder": "受信者のメールアドレス(カンマ区切り)",
"cc_placeholder": "CC受信者(カンマ区切り)",
"bcc_placeholder": "BCC受信者(カンマ区切り)",
"subject_placeholder": "件名",
"cc_label": "CC:",
"bcc_label": "BCC:",
"subject_label": "件名:",
"file_size_kb": "KB",
"prefix": {
"forward": "Fwd:",
"reply": "Re:"
},
"no_subject": "(件名なし)",
"unknown_sender": "不明",
"quote": {
"reply_header": "{{date}}に{{sender}}が書きました:",
"forward_header": "---------- 転送メッセージ ----------",
"from": "送信者: {{sender}}",
"date": "日付: {{date}}",
"subject": "件名: {{subject}}",
"to": "宛先: {{recipients}}"
},
"remove_sub_address": "サブアドレスを削除"
},
"common": {
"loading": "読み込み中...",
"error": "エラー",
"success": "成功",
"cancel": "キャンセル",
"save": "保存",
"delete": "削除",
"edit": "編集",
"close": "閉じる",
"search": "検索",
"refresh": "更新",
"settings": "設定",
"help": "ヘルプ",
"logout": "ログアウト",
"yes": "はい",
"no": "いいえ",
"unknown": "不明",
"app_title": "ウェブメール"
},
"notifications": {
"email_sent": "メールを送信しました",
"email_deleted": "メールを削除しました",
"email_archived": "メールをアーカイブしました",
"email_starred": "スターを付けました",
"email_unstarred": "スターを外しました",
"email_marked_read": "既読にしました",
"email_marked_unread": "未読にしました",
"copied_to_clipboard": "クリップボードにコピーしました",
"source_copied": "ソースをクリップボードにコピーしました",
"error_sending": "メールの送信に失敗しました",
"error_deleting": "メールの削除に失敗しました",
"error_loading": "メールの読み込みに失敗しました",
"new_email": "新しいメール",
"new_email_from": "{sender}から",
"click_to_view": "クリックして表示",
"email_moved": "メールを移動しました",
"emails_moved": "{count}件のメールを移動しました",
"moved_to_mailbox": "{mailbox}に移動しました",
"move_failed": "移動に失敗しました",
"move_error": "選択したフォルダにメールを移動できませんでした",
"identity_created": "送信者情報を作成しました",
"identity_updated": "送信者情報を更新しました",
"identity_deleted": "送信者情報を削除しました",
"identity_create_failed": "送信者情報の作成に失敗しました: {{error}}",
"identity_update_failed": "送信者情報の更新に失敗しました: {{error}}",
"identity_delete_failed": "送信者情報の削除に失敗しました: {{error}}",
"identity_unauthorized": "このメールアドレスからの送信は許可されていません",
"identity_not_found": "送信者情報が見つかりません"
},
"date": {
"today": "今日",
"yesterday": "昨日",
"this_week": "今週",
"last_week": "先週",
"this_month": "今月",
"older": "それ以前",
"just_now": "たった今",
"minutes_ago": "{{count}}分前",
"minutes_ago_plural": "{{count}}分前",
"hours_ago": "{{count}}時間前",
"hours_ago_plural": "{{count}}時間前",
"days_ago": "{{count}}日前",
"days_ago_plural": "{{count}}日前"
},
"language": {
"title": "言語",
"english": "English",
"french": "Français",
"japanese": "日本語",
"spanish": "Español",
"italian": "Italiano",
"german": "Deutsch",
"dutch": "Nederlands",
"portuguese": "Português",
"select_language": "言語を選択",
"switch_to_english": "英語に切り替え",
"switch_to_french": "フランス語に切り替え",
"switch_to_japanese": "日本語に切り替え",
"switch_to_spanish": "スペイン語に切り替え",
"switch_to_italian": "イタリア語に切り替え",
"switch_to_german": "ドイツ語に切り替え",
"switch_to_dutch": "オランダ語に切り替え",
"switch_to_portuguese": "ポルトガル語に切り替え",
"switching": "言語を変更中..."
},
"settings": {
"title": "設定",
"back_to_mail": "メールに戻る",
"save_success": "設定を保存しました",
"import_success": "設定をインポートしました",
"import_error": "設定のインポートに失敗しました",
"reset_confirm": "すべての設定をデフォルトにリセットしてもよろしいですか?",
"tabs": {
"appearance": "外観",
"language": "言語と地域",
"email": "メール動作",
"composer": "作成",
"privacy": "プライバシーとセキュリティ",
"account": "アカウント",
"identities": "送信者情報",
"advanced": "詳細設定"
},
"appearance": {
"title": "外観",
"description": "ウェブメールの見た目をカスタマイズ",
"theme": {
"label": "テーマ",
"description": "お好みの配色を選択",
"light": "ライト",
"dark": "ダーク",
"system": "システム"
},
"language": {
"label": "言語",
"description": "お好みの言語を選択"
},
"font_size": {
"label": "フォントサイズ",
"description": "読みやすさのためにテキストサイズを調整",
"small": "小",
"medium": "中",
"large": "大"
},
"list_density": {
"label": "リストの密度",
"description": "メールリストの間隔を調整",
"compact": "コンパクト",
"regular": "標準",
"comfortable": "ゆったり"
},
"animations": {
"label": "アニメーションを有効にする",
"description": "スムーズな遷移とエフェクトを表示"
}
},
"language_region": {
"title": "言語と地域",
"description": "言語と地域の設定を構成",
"language": {
"label": "言語",
"description": "お好みの言語を選択",
"english": "English",
"french": "Français"
},
"date_format": {
"label": "日付形式",
"description": "日付の表示形式",
"regional": "地域設定",
"iso": "ISO 8601",
"custom": "カスタム"
},
"time_format": {
"label": "時刻形式",
"description": "12時間制または24時間制を選択",
"12h": "12時間制",
"24h": "24時間制"
},
"first_day": {
"label": "週の開始日",
"description": "日曜日または月曜日から開始",
"sunday": "日曜日",
"monday": "月曜日"
}
},
"email_behavior": {
"title": "メール動作",
"description": "メールの処理方法を設定",
"mark_read": {
"label": "既読にする",
"description": "メールを開いたときに既読にするタイミング",
"instant": "即座",
"delay_3s": "3秒後",
"delay_5s": "5秒後",
"never": "既読にしない"
},
"delete_action": {
"label": "削除動作",
"description": "メール削除時の動作",
"trash": "ゴミ箱に移動",
"permanent": "完全に削除"
},
"show_preview": {
"label": "プレビューテキストを表示",
"description": "リストにメールのプレビューを表示"
},
"emails_per_page": {
"label": "ページごとのメール数",
"description": "一度に読み込むメールの数",
"25": "25件",
"50": "50件",
"100": "100件"
},
"external_content": {
"label": "外部コンテンツ",
"description": "画像と外部コンテンツの処理方法",
"ask": "常に確認",
"block": "常にブロック",
"allow": "常に許可"
},
"trusted_senders": {
"label": "信頼する送信者",
"description": "画像が自動的に読み込まれる送信者を管理",
"count_zero": "なし",
"count_one": "1件",
"count_other": "{count}件",
"modal_title": "信頼する送信者",
"empty_title": "信頼する送信者はまだありません",
"empty_description": "ブロックされた画像を含むメールを表示するときに、「常にこの送信者を信頼する」をクリックしてここに追加します。",
"add_manually": "送信者を手動で追加",
"add_button": "追加",
"add_placeholder": "メールアドレスを入力",
"search_placeholder": "送信者を検索...",
"no_results": "検索に一致する送信者がありません",
"remove": "削除",
"close": "閉じる",
"invalid_email": "有効なメールアドレスを入力してください",
"already_added": "この送信者はすでに信頼されています"
}
},
"composer": {
"title": "作成",
"description": "メール作成の設定を構成",
"autosave": {
"label": "自動保存間隔",
"description": "下書きを自動保存する頻度",
"30s": "30秒ごと",
"1m": "1分ごと",
"2m": "2分ごと",
"5m": "5分ごと"
},
"send_confirmation": {
"label": "送信確認",
"description": "メール送信前に確認を求める"
},
"default_reply": {
"label": "デフォルトの返信モード",
"description": "返信をクリックしたときのデフォルト動作",
"reply": "返信",
"reply_all": "全員に返信"
}
},
"privacy": {
"title": "プライバシーとセキュリティ",
"description": "プライバシーとセキュリティ設定を管理",
"external_images": {
"label": "外部画像をブロック",
"description": "外部画像による追跡を防止"
},
"session_timeout": {
"label": "セッションタイムアウト",
"description": "非アクティブ時に自動的にログアウト",
"never": "なし",
"30m": "30分",
"1h": "1時間",
"4h": "4時間"
},
"clear_cache": {
"label": "キャッシュをクリア",
"description": "キャッシュデータと一時ファイルを削除",
"button": "キャッシュをクリア",
"confirm": "キャッシュをクリアしてもよろしいですか?",
"success": "キャッシュをクリアしました"
}
},
"account": {
"title": "アカウント",
"description": "アカウント情報を表示",
"email": {
"label": "メールアドレス",
"value": "{{email}}"
},
"server": {
"label": "JMAPサーバー",
"value": "{{server}}"
},
"storage": {
"label": "ストレージ使用量",
"used": "{{total}}中{{used}}使用",
"percentage": "{{percent}}%使用"
},
"last_sync": {
"label": "最終同期",
"value": "{{time}}"
}
},
"identities": {
"title": "送信者情報",
"description": "送信に使用するメールアドレスを管理",
"identities_count": {
"label": "送信者情報",
"description": "送信用に設定されたメールアドレス",
"count_zero": "送信者情報なし",
"count_one": "1件",
"count_other": "{{count}}件"
},
"manage": "送信者情報を管理",
"sub_addressing": {
"label": "サブアドレス",
"description": "user+tag@domain.comのようなタグを使用して受信メールを整理",
"learn_more": "詳細"
}
},
"advanced": {
"title": "詳細設定",
"description": "詳細オプションと開発者設定",
"debug_mode": {
"label": "デバッグモード",
"description": "トラブルシューティング用の詳細ログを有効化"
},
"keyboard_shortcuts": {
"label": "キーボードショートカット",
"description": "利用可能なキーボードショートカットを表示",
"button": "ショートカットを表示"
},
"reset_settings": {
"label": "設定をリセット",
"description": "すべての設定をデフォルト値に戻す",
"button": "デフォルトにリセット"
},
"export_settings": {
"label": "設定をエクスポート",
"description": "設定をJSONとしてダウンロード",
"button": "エクスポート"
},
"import_settings": {
"label": "設定をインポート",
"description": "JSONファイルから設定をアップロード",
"button": "インポート"
}
}
},
"errors": {
"page_error_title": "問題が発生しました",
"page_error_description": "予期しないエラーが発生しました。もう一度お試しいただくか、ホームページに戻ってください。",
"sidebar_error": "メールボックスを読み込めません",
"email_list_error": "メールを読み込めません",
"viewer_error_title": "メールを表示できません",
"viewer_error_description": "このメールのレンダリングに問題が発生しました。サポートされていないコンテンツが含まれている可能性があります。",
"composer_error": "作成画面を読み込めません",
"settings_error_title": "設定を利用できません",
"settings_error_description": "設定を読み込めません。設定が保存されない可能性があります。",
"try_again": "再試行",
"reload": "再読み込み",
"reload_emails": "メールを再読み込み",
"reload_settings": "設定を再読み込み",
"retry": "リトライ",
"go_home": "受信トレイに移動"
},
"context_menu": {
"reply": "返信",
"reply_all": "全員に返信",
"forward": "転送",
"mark_read": "既読にする",
"mark_unread": "未読にする",
"star": "スターを付ける",
"unstar": "スターを外す",
"move_to": "移動...",
"archive": "アーカイブ",
"delete": "削除",
"mark_as_spam": "迷惑メールを報告",
"not_spam": "迷惑メールでない",
"color_tag": "カラータグ",
"remove_color": "色を削除",
"items_selected": "{{count}}件のメールを選択"
},
"shortcuts": {
"title": "キーボードショートカット",
"tip": "? キーを押すといつでもこのヘルプを表示できます",
"sections": {
"navigation": "ナビゲーション",
"actions": "メール操作",
"global": "全般",
"threads": "スレッド"
},
"navigation": {
"next_email": "次のメール",
"previous_email": "前のメール",
"open_email": "メールを開く",
"close_email": "閉じる / 選択解除"
},
"actions": {
"reply": "返信",
"reply_all": "全員に返信",
"forward": "転送",
"star": "スターの切り替え",
"archive": "アーカイブ",
"delete": "削除",
"mark_unread": "未読にする",
"mark_read": "既読にする",
"toggle_spam": "迷惑メールを報告 / 迷惑メールでない"
},
"global": {
"compose": "新規メールを作成",
"search": "検索にフォーカス",
"help": "ショートカットを表示",
"refresh": "メールを更新",
"select_all": "すべて選択"
},
"threads": {
"expand_collapse": "スレッドの展開/折りたたみ"
}
},
"threads": {
"messages_one": "{count}件のメッセージ",
"messages_other": "{count}件のメッセージ",
"expand": "会話を展開",
"collapse": "会話を折りたたむ",
"loading": "会話を読み込み中...",
"mark_read": "会話を既読にする",
"mark_unread": "会話を未読にする",
"archive": "会話をアーカイブ",
"delete": "会話を削除",
"star": "会話にスターを付ける",
"unstar": "会話のスターを外す"
},
"identities": {
"modal_title": "送信者情報を管理",
"create_new": "新しい送信者情報を作成",
"edit_identity": "送信者情報を編集",
"delete_confirm": "この送信者情報を削除しますか?この操作は元に戻せません。",
"cannot_delete": "この送信者情報は削除できません",
"primary_identity": "プライマリ",
"no_identities": "送信者情報が見つかりません",
"display": {
"reply_to": "返信先:",
"bcc": "BCC:",
"signature": "署名:",
"preview": "プレビュー:"
},
"validation_errors": {
"invalid_emails": "無効なメールアドレス: {emails}",
"unknown_error": "不明なエラー"
},
"form": {
"name_label": "表示名",
"name_placeholder": "例: 仕事用、個人用",
"name_required": "名前は必須です",
"email_label": "メールアドレス",
"email_placeholder": "your.email@example.com",
"email_required": "メールアドレスは必須です",
"email_invalid": "有効なメールアドレスを入力してください",
"email_immutable": "メールアドレスは作成後に変更できません",
"reply_to_label": "返信先(オプション)",
"reply_to_placeholder": "different@email.com",
"bcc_label": "自動BCC(オプション)",
"bcc_placeholder": "archive@email.com",
"text_signature_label": "テキスト署名",
"html_signature_label": "HTML署名",
"save": "送信者情報を保存",
"cancel": "キャンセル",
"creating": "作成中...",
"updating": "更新中..."
},
"sub_address": {
"button_tooltip": "サブアドレスを使用",
"popover_title": "サブアドレスタグを追加",
"tag_input_placeholder": "タグを入力(例: shopping",
"preview_label": "プレビュー:",
"recent_tags": "最近のタグ",
"suggested_tags": "候補",
"use_address": "このアドレスを使用",
"invalid_tag": "タグは英数字とハイフンのみ使用できます",
"tag_too_long": "タグは30文字以内にしてください",
"help_text": "user+tag@domain.comに送信されたメールは受信トレイに届きます",
"validation": {
"empty": "タグは空にできません",
"too_long": "タグは{max}文字以内にしてください",
"invalid_chars": "タグは文字、数字、ハイフンのみ使用できます"
}
},
"badge": {
"sent_via": "経由",
"sub_address_tag": "サブアドレスを使用して送信: {tag}",
"identity_name": "送信者情報を使用して送信: {name}",
"identity_short": "{name}経由",
"subaddress_tag": "+{tag}"
}
}
}
+746
View File
@@ -0,0 +1,746 @@
{
"login": {
"title": "Webmail",
"username_label": "E-mail",
"username_placeholder": "gebruiker@voorbeeld.nl",
"password_label": "Wachtwoord",
"password_placeholder": "Voer je wachtwoord in",
"sign_in": "Aanmelden",
"signing_in": "Aanmelden...",
"loading": "Laden...",
"error": {
"invalid_credentials": "Ongeldig e-mailadres of wachtwoord",
"connection_failed": "Kan geen verbinding maken met de server",
"generic": "Er is een fout opgetreden. Probeer het opnieuw."
},
"config_error": {
"title": "Configuratiefout",
"fetch_failed": "Kan de applicatieconfiguratie niet laden. Probeer het later opnieuw.",
"server_not_configured": "De mailserver is niet geconfigureerd. Neem contact op met je beheerder."
},
"remove_from_history": "Verwijder uit geschiedenis"
},
"sidebar": {
"close": "Sluiten",
"compose": "Nieuw bericht",
"search_placeholder": "Zoeken in e-mail...",
"storage": "Opslag",
"sign_out": "Afmelden",
"settings": "Instellingen",
"loading_mailboxes": "Mappen laden...",
"push_connected": "Real-time updates actief",
"push_disconnected": "Real-time updates inactief",
"theme": {
"light": "Lichte modus",
"dark": "Donkere modus",
"system": "Systeemthema"
},
"language": {
"title": "Taal"
},
"mailboxes": {
"inbox": "Postvak IN",
"sent": "Verzonden",
"drafts": "Concepten",
"trash": "Prullenbak",
"archive": "Archief",
"starred": "Met ster",
"all_mail": "Alle e-mail",
"spam": "Spam",
"important": "Belangrijk"
},
"expand": "Uitklappen",
"collapse": "Inklappen",
"expand_tooltip": "Uitklappen",
"collapse_tooltip": "Inklappen",
"mobile": {
"search": "Zoeken",
"compose": "Opstellen",
"go_back": "Terug"
},
"clear_search": "Zoekopdracht wissen"
},
"email_list": {
"no_emails": "Geen e-mails",
"no_emails_description": "Begin met het opstellen van een nieuw bericht",
"loading": "E-mails laden...",
"unread": "ongelezen",
"to_me": "Aan mij",
"to_recipients": "Aan {{count}} ontvangers",
"and_others": "en {{count}} anderen",
"draft": "Concept",
"starred": "Met ster",
"conversations_count": "{count} van {total} gesprekken",
"conversations_count_plus": "{count}+ gesprekken",
"conversations_count_simple": "{count} gesprekken",
"no_conversations": "Geen gesprekken",
"loading_more": "Meer e-mails laden...",
"no_more_emails": "Geen e-mails meer om te laden",
"batch_actions": {
"mark_read": "Markeren als gelezen",
"mark_unread": "Markeren als ongelezen",
"delete": "Verwijderen",
"clear_selection": "Selectie wissen"
}
},
"email_viewer": {
"no_email_selected": "Geen e-mail geselecteerd",
"no_email_description": "Selecteer een e-mail uit de lijst om deze hier te bekijken",
"no_conversation_selected": "Geen gesprek geselecteerd",
"no_conversation_description": "Kies een gesprek uit de lijst om het hier te lezen",
"no_subject": "(Geen onderwerp)",
"loading_email": "E-mail laden...",
"loading": "Laden...",
"reply": "Beantwoorden",
"reply_all": "Allen beantwoorden",
"forward": "Doorsturen",
"delete": "Verwijderen",
"archive": "Archiveren",
"star": "Ster toevoegen",
"unstar": "Ster verwijderen",
"mark_unread": "Markeren als ongelezen",
"mark_read": "Markeren als gelezen",
"print": "Afdrukken",
"view_source": "Bron bekijken",
"email_source": "E-mailbron",
"copy_source": "Kopiëren naar klembord",
"source_copied": "Bron gekopieerd naar klembord",
"attachments": "Bijlagen",
"important": "Belangrijk",
"download": "Downloaden",
"from": "Van",
"to": "Aan",
"cc": "CC",
"bcc": "BCC",
"date": "Datum",
"subject": "Onderwerp",
"show_details": "Details tonen",
"hide_details": "Details verbergen",
"external_content_warning": "Afbeeldingen en externe inhoud zijn geblokkeerd",
"load_external_content": "Afbeeldingen laden",
"trust_sender": "Deze afzender altijd vertrouwen",
"back_to_list": "Terug naar lijst",
"message_details": "Berichtdetails",
"more_reply_options": "Meer antwoordopties",
"set_color": "Kleur instellen",
"more_actions": "Meer acties",
"remove_color": "Kleur verwijderen",
"more_count": "+{count} meer",
"characters_count": "{count} tekens",
"quick_reply_placeholder": "Schrijf een snel antwoord...",
"more_options": "Meer opties",
"sending": "Verzenden...",
"security_authentication": "Beveiliging & Authenticatie",
"technical_details": "Technische details",
"message_id_label": "Bericht-ID:",
"reply_to_label": "Antwoord naar:",
"delivery_time_label": "Bezorgtijd:",
"conversation_part_label": "Onderdeel van gesprek:",
"previous_messages": "{count} vorig bericht",
"previous_messages_plural": "{count} vorige berichten",
"time": {
"day": "dag",
"days": "dagen",
"hour": "uur",
"hours": "uur",
"minute": "minuut",
"minutes": "minuten"
},
"unknown_sender": "Onbekend",
"recipient_me": "mij",
"recipient_and_others": "{name} en {count} anderen",
"recipient_to_prefix": "Aan:",
"authentication": {
"title": "Authenticatie",
"status": {
"verified": "Geverifieerd",
"warning": "Waarschuwing",
"none": "Niet geauthenticeerd"
},
"spf": {
"pass": "SPF Geslaagd",
"fail": "SPF Mislukt",
"none": "Geen SPF"
},
"dkim": {
"pass": "DKIM Geldig",
"fail": "DKIM Ongeldig",
"none": "Geen DKIM"
},
"dmarc": {
"pass": "DMARC Geslaagd",
"fail": "DMARC Mislukt",
"none": "Geen DMARC"
},
"spam_score": "Spamscore"
},
"headers": {
"routing": "Routering",
"received": "Ontvangen",
"message_id": "Bericht-ID",
"list_info": "Lijstinformatie"
},
"color_tag": {
"title": "Kleurtag",
"red": "Rood",
"orange": "Oranje",
"yellow": "Geel",
"green": "Groen",
"blue": "Blauw",
"purple": "Paars",
"pink": "Roze",
"none": "Geen"
},
"tooltips": {
"reply": "Beantwoorden",
"archive": "Archiveren",
"delete": "Verwijderen"
},
"spam": {
"button_title": "Spam melden",
"not_spam_title": "Markeren als legitiem",
"toast_success": "Verplaatst naar Ongewenst",
"toast_batch": "{count} e-mails verplaatst naar Ongewenst",
"toast_undo": "Ongedaan maken",
"toast_not_spam_success": "Verplaatst naar Postvak IN",
"toast_not_spam_batch": "{count} e-mails verplaatst naar Postvak IN",
"error": "Kan spam niet melden",
"error_not_spam": "Kan e-mail niet herstellen"
},
"unsubscribe_banner": {
"label": "Nieuwsbrief",
"button": "Uitschrijven",
"confirm_title": "Uitschrijven van deze afzender?",
"confirm_button": "Bevestigen",
"cancel": "Annuleren",
"success_http": "Uitschrijfpagina geopend in nieuw tabblad",
"success_mailto": "Uitschrijfverzoek verzonden naar je e-mailclient",
"error": "Kan niet uitschrijven",
"dismiss": "Sluiten"
}
},
"email_composer": {
"new_message": "Nieuw bericht",
"reply": "Beantwoorden",
"reply_all": "Allen beantwoorden",
"forward": "Doorsturen",
"reply_to": "Beantwoorden",
"reply_all_to": "Allen beantwoorden",
"forward_message": "Doorsturen",
"from": "Van",
"to": "Aan",
"cc": "CC",
"bcc": "BCC",
"subject": "Onderwerp",
"body_placeholder": "Schrijf je bericht...",
"send": "Verzenden",
"cancel": "Annuleren",
"attach": "Bijlage toevoegen",
"discard": "Verwijderen",
"discard_draft_confirm": "Je hebt niet-opgeslagen wijzigingen. Wil je dit concept verwijderen?",
"saving": "Opslaan...",
"draft_saved": "Concept opgeslagen",
"save_failed": "Opslaan mislukt",
"to_placeholder": "E-mailadressen van ontvangers (komma gescheiden)",
"cc_placeholder": "CC-ontvangers (komma gescheiden)",
"bcc_placeholder": "BCC-ontvangers (komma gescheiden)",
"subject_placeholder": "Onderwerp",
"cc_label": "CC:",
"bcc_label": "BCC:",
"subject_label": "Onderwerp:",
"file_size_kb": "KB",
"prefix": {
"forward": "Fwd:",
"reply": "Re:"
},
"no_subject": "(Geen onderwerp)",
"unknown_sender": "Onbekend",
"quote": {
"reply_header": "Op {{date}} schreef {{sender}}:",
"forward_header": "---------- Doorgestuurd bericht ----------",
"from": "Van: {{sender}}",
"date": "Datum: {{date}}",
"subject": "Onderwerp: {{subject}}",
"to": "Aan: {{recipients}}"
},
"remove_sub_address": "Sub-adres verwijderen"
},
"common": {
"loading": "Laden...",
"error": "Fout",
"success": "Geslaagd",
"cancel": "Annuleren",
"save": "Opslaan",
"delete": "Verwijderen",
"edit": "Bewerken",
"close": "Sluiten",
"search": "Zoeken",
"refresh": "Vernieuwen",
"settings": "Instellingen",
"help": "Help",
"logout": "Uitloggen",
"yes": "Ja",
"no": "Nee",
"unknown": "Onbekend",
"app_title": "Webmail"
},
"notifications": {
"email_sent": "E-mail succesvol verzonden",
"email_deleted": "E-mail verwijderd",
"email_archived": "E-mail gearchiveerd",
"email_starred": "Ster toegevoegd aan e-mail",
"email_unstarred": "Ster verwijderd van e-mail",
"email_marked_read": "E-mail gemarkeerd als gelezen",
"email_marked_unread": "E-mail gemarkeerd als ongelezen",
"copied_to_clipboard": "Gekopieerd naar klembord",
"source_copied": "Bron gekopieerd naar klembord",
"error_sending": "Kan e-mail niet verzenden",
"error_deleting": "Kan e-mail niet verwijderen",
"error_loading": "Kan e-mails niet laden",
"new_email": "Nieuwe e-mail",
"new_email_from": "Van {sender}",
"click_to_view": "Klik om te bekijken",
"email_moved": "E-mail verplaatst",
"emails_moved": "{count} e-mails verplaatst",
"moved_to_mailbox": "Verplaatst naar {mailbox}",
"move_failed": "Verplaatsen mislukt",
"move_error": "Kan e-mails niet verplaatsen naar de geselecteerde map",
"identity_created": "Identiteit succesvol aangemaakt",
"identity_updated": "Identiteit succesvol bijgewerkt",
"identity_deleted": "Identiteit verwijderd",
"identity_create_failed": "Kan identiteit niet aanmaken: {{error}}",
"identity_update_failed": "Kan identiteit niet bijwerken: {{error}}",
"identity_delete_failed": "Kan identiteit niet verwijderen: {{error}}",
"identity_unauthorized": "Je bent niet geautoriseerd om vanaf dit e-mailadres te verzenden",
"identity_not_found": "Identiteit niet gevonden"
},
"date": {
"today": "Vandaag",
"yesterday": "Gisteren",
"this_week": "Deze week",
"last_week": "Vorige week",
"this_month": "Deze maand",
"older": "Ouder",
"just_now": "Zojuist",
"minutes_ago": "{{count}} minuut geleden",
"minutes_ago_plural": "{{count}} minuten geleden",
"hours_ago": "{{count}} uur geleden",
"hours_ago_plural": "{{count}} uur geleden",
"days_ago": "{{count}} dag geleden",
"days_ago_plural": "{{count}} dagen geleden"
},
"language": {
"title": "Taal",
"english": "English",
"french": "Français",
"japanese": "日本語",
"spanish": "Español",
"italian": "Italiano",
"german": "Deutsch",
"dutch": "Nederlands",
"portuguese": "Português",
"select_language": "Selecteer taal",
"switch_to_english": "Overschakelen naar Engels",
"switch_to_french": "Overschakelen naar Frans",
"switch_to_japanese": "Overschakelen naar Japans",
"switch_to_spanish": "Overschakelen naar Spaans",
"switch_to_italian": "Overschakelen naar Italiaans",
"switch_to_german": "Overschakelen naar Duits",
"switch_to_dutch": "Overschakelen naar Nederlands",
"switch_to_portuguese": "Overschakelen naar Portugees",
"switching": "Taal wijzigen..."
},
"settings": {
"title": "Instellingen",
"back_to_mail": "Terug naar Mail",
"save_success": "Instellingen succesvol opgeslagen",
"import_success": "Instellingen succesvol geïmporteerd",
"import_error": "Kan instellingen niet importeren",
"reset_confirm": "Weet je zeker dat je alle instellingen wilt resetten naar de standaardwaarden?",
"tabs": {
"appearance": "Uiterlijk",
"language": "Taal & Regio",
"email": "E-mailgedrag",
"composer": "Opstellen",
"privacy": "Privacy & Beveiliging",
"account": "Account",
"identities": "Identiteiten",
"advanced": "Geavanceerd"
},
"appearance": {
"title": "Uiterlijk",
"description": "Pas het uiterlijk van je webmail aan",
"theme": {
"label": "Thema",
"description": "Kies je voorkeurkleurenschema",
"light": "Licht",
"dark": "Donker",
"system": "Systeem"
},
"language": {
"label": "Taal",
"description": "Kies je voorkeurstaal"
},
"font_size": {
"label": "Lettergrootte",
"description": "Pas de tekstgrootte aan voor betere leesbaarheid",
"small": "Klein",
"medium": "Middel",
"large": "Groot"
},
"list_density": {
"label": "Lijstdichtheid",
"description": "Regeleer de ruimte in e-maillijsten",
"compact": "Compact",
"regular": "Normaal",
"comfortable": "Comfortabel"
},
"animations": {
"label": "Animaties inschakelen",
"description": "Vloeiende overgangen en effecten tonen"
}
},
"language_region": {
"title": "Taal & Regio",
"description": "Configureer taal- en regiovoorkeuren",
"language": {
"label": "Taal",
"description": "Kies je voorkeurstaal",
"english": "English",
"french": "Français"
},
"date_format": {
"label": "Datumnotatie",
"description": "Hoe datums moeten worden weergegeven",
"regional": "Regionaal",
"iso": "ISO 8601",
"custom": "Aangepast"
},
"time_format": {
"label": "Tijdnotatie",
"description": "Kies tussen 12-uurs of 24-uurs klok",
"12h": "12-uurs",
"24h": "24-uurs"
},
"first_day": {
"label": "Eerste dag van de week",
"description": "Begin de week op zondag of maandag",
"sunday": "Zondag",
"monday": "Maandag"
}
},
"email_behavior": {
"title": "E-mailgedrag",
"description": "Configureer hoe e-mails worden verwerkt",
"mark_read": {
"label": "Markeren als gelezen",
"description": "Wanneer e-mails als gelezen worden gemarkeerd bij openen",
"instant": "Direct",
"delay_3s": "Na 3 seconden",
"delay_5s": "Na 5 seconden",
"never": "Nooit"
},
"delete_action": {
"label": "Verwijderactie",
"description": "Wat er gebeurt wanneer je een e-mail verwijdert",
"trash": "Verplaatsen naar prullenbak",
"permanent": "Permanent verwijderen"
},
"show_preview": {
"label": "Voorbeeldtekst tonen",
"description": "E-mailvoorbeeld weergeven in de lijst"
},
"emails_per_page": {
"label": "E-mails per pagina",
"description": "Aantal e-mails dat in één keer wordt geladen",
"25": "25 e-mails",
"50": "50 e-mails",
"100": "100 e-mails"
},
"external_content": {
"label": "Externe inhoud",
"description": "Hoe afbeeldingen en externe inhoud moeten worden verwerkt",
"ask": "Altijd vragen",
"block": "Altijd blokkeren",
"allow": "Altijd toestaan"
},
"trusted_senders": {
"label": "Vertrouwde afzenders",
"description": "Beheer afzenders van wie afbeeldingen automatisch worden geladen",
"count_zero": "Geen",
"count_one": "1 afzender",
"count_other": "{count} afzenders",
"modal_title": "Vertrouwde afzenders",
"empty_title": "Nog geen vertrouwde afzenders",
"empty_description": "Wanneer je een e-mail bekijkt met geblokkeerde afbeeldingen, klik op \"Deze afzender altijd vertrouwen\" om ze hier toe te voegen.",
"add_manually": "Afzender handmatig toevoegen",
"add_button": "Toevoegen",
"add_placeholder": "Voer e-mailadres in",
"search_placeholder": "Zoek afzenders...",
"no_results": "Geen afzenders komen overeen met je zoekopdracht",
"remove": "Verwijderen",
"close": "Sluiten",
"invalid_email": "Voer een geldig e-mailadres in",
"already_added": "Deze afzender wordt al vertrouwd"
}
},
"composer": {
"title": "Opstellen",
"description": "Configureer instellingen voor het opstellen van e-mails",
"autosave": {
"label": "Automatisch opslaan interval",
"description": "Hoe vaak concepten automatisch worden opgeslagen",
"30s": "Elke 30 seconden",
"1m": "Elke minuut",
"2m": "Elke 2 minuten",
"5m": "Elke 5 minuten"
},
"send_confirmation": {
"label": "Verzendbevestiging",
"description": "Vraag om bevestiging voordat e-mails worden verzonden"
},
"default_reply": {
"label": "Standaard antwoordmodus",
"description": "Standaardactie bij klikken op beantwoorden",
"reply": "Beantwoorden",
"reply_all": "Allen beantwoorden"
}
},
"privacy": {
"title": "Privacy & Beveiliging",
"description": "Beheer je privacy- en beveiligingsinstellingen",
"external_images": {
"label": "Externe afbeeldingen blokkeren",
"description": "Voorkom tracking via externe afbeeldingen"
},
"session_timeout": {
"label": "Sessie time-out",
"description": "Automatisch uitloggen na inactiviteit",
"never": "Nooit",
"30m": "30 minuten",
"1h": "1 uur",
"4h": "4 uur"
},
"clear_cache": {
"label": "Cache wissen",
"description": "Verwijder gecachte gegevens en tijdelijke bestanden",
"button": "Cache wissen",
"confirm": "Weet je zeker dat je de cache wilt wissen?",
"success": "Cache succesvol gewist"
}
},
"account": {
"title": "Account",
"description": "Bekijk je accountinformatie",
"email": {
"label": "E-mailadres",
"value": "{{email}}"
},
"server": {
"label": "JMAP-server",
"value": "{{server}}"
},
"storage": {
"label": "Opslaggebruik",
"used": "{{used}} van {{total}} gebruikt",
"percentage": "{{percent}}% gebruikt"
},
"last_sync": {
"label": "Laatste synchronisatie",
"value": "{{time}}"
}
},
"identities": {
"title": "Verzendidentiteiten",
"description": "Beheer e-mailadressen van waaruit je kunt verzenden",
"identities_count": {
"label": "Je identiteiten",
"description": "E-mailadressen geconfigureerd voor verzenden",
"count_zero": "Geen identiteiten",
"count_one": "1 identiteit",
"count_other": "{{count}} identiteiten"
},
"manage": "Identiteiten beheren",
"sub_addressing": {
"label": "Sub-adressering",
"description": "Gebruik tags zoals gebruiker+tag@domein.nl om inkomende mail te organiseren",
"learn_more": "Meer informatie"
}
},
"advanced": {
"title": "Geavanceerd",
"description": "Geavanceerde opties en ontwikkelaarsinstellingen",
"debug_mode": {
"label": "Debugmodus",
"description": "Schakel gedetailleerde logging in voor probleemoplossing"
},
"keyboard_shortcuts": {
"label": "Sneltoetsen",
"description": "Bekijk beschikbare sneltoetsen",
"button": "Sneltoetsen bekijken"
},
"reset_settings": {
"label": "Instellingen resetten",
"description": "Herstel alle instellingen naar standaardwaarden",
"button": "Resetten naar standaard"
},
"export_settings": {
"label": "Instellingen exporteren",
"description": "Download je instellingen als JSON",
"button": "Exporteren"
},
"import_settings": {
"label": "Instellingen importeren",
"description": "Upload instellingen vanuit JSON-bestand",
"button": "Importeren"
}
}
},
"errors": {
"page_error_title": "Er is iets misgegaan",
"page_error_description": "We hebben een onverwachte fout ondervonden. Probeer het opnieuw of keer terug naar de startpagina.",
"sidebar_error": "Kan mappen niet laden",
"email_list_error": "Kan e-mails niet laden",
"viewer_error_title": "Kan e-mail niet weergeven",
"viewer_error_description": "Er was een probleem met het renderen van deze e-mail. Het kan niet-ondersteunde inhoud bevatten.",
"composer_error": "Kan opsteller niet laden",
"settings_error_title": "Instellingen niet beschikbaar",
"settings_error_description": "Kan instellingen niet laden. Je voorkeuren worden mogelijk niet opgeslagen.",
"try_again": "Opnieuw proberen",
"reload": "Herladen",
"reload_emails": "E-mails herladen",
"reload_settings": "Instellingen herladen",
"retry": "Opnieuw proberen",
"go_home": "Ga naar postvak IN"
},
"context_menu": {
"reply": "Beantwoorden",
"reply_all": "Allen beantwoorden",
"forward": "Doorsturen",
"mark_read": "Markeren als gelezen",
"mark_unread": "Markeren als ongelezen",
"star": "Ster toevoegen",
"unstar": "Ster verwijderen",
"move_to": "Verplaatsen naar...",
"archive": "Archiveren",
"delete": "Verwijderen",
"mark_as_spam": "Spam melden",
"not_spam": "Geen spam",
"color_tag": "Kleurtag",
"remove_color": "Kleur verwijderen",
"items_selected": "{{count}} e-mails geselecteerd"
},
"shortcuts": {
"title": "Sneltoetsen",
"tip": "Druk op ? om deze hulp te tonen",
"sections": {
"navigation": "Navigatie",
"actions": "E-mailacties",
"global": "Globaal",
"threads": "Gesprekken"
},
"navigation": {
"next_email": "Volgende e-mail",
"previous_email": "Vorige e-mail",
"open_email": "E-mail openen",
"close_email": "Sluiten / Deselecteren"
},
"actions": {
"reply": "Beantwoorden",
"reply_all": "Allen beantwoorden",
"forward": "Doorsturen",
"star": "Ster aan/uit",
"archive": "Archiveren",
"delete": "Verwijderen",
"mark_unread": "Markeren als ongelezen",
"mark_read": "Markeren als gelezen",
"toggle_spam": "Spam melden / Geen spam"
},
"global": {
"compose": "Nieuw bericht opstellen",
"search": "Focus op zoeken",
"help": "Sneltoetsen tonen",
"refresh": "E-mails vernieuwen",
"select_all": "Alles selecteren"
},
"threads": {
"expand_collapse": "Gesprek uitklappen/inklappen"
}
},
"threads": {
"messages_one": "{count} bericht",
"messages_other": "{count} berichten",
"expand": "Gesprek uitklappen",
"collapse": "Gesprek inklappen",
"loading": "Gesprek laden...",
"mark_read": "Gesprek markeren als gelezen",
"mark_unread": "Gesprek markeren als ongelezen",
"archive": "Gesprek archiveren",
"delete": "Gesprek verwijderen",
"star": "Ster toevoegen aan gesprek",
"unstar": "Ster verwijderen van gesprek"
},
"identities": {
"modal_title": "Verzendidentiteiten beheren",
"create_new": "Nieuwe identiteit aanmaken",
"edit_identity": "Identiteit bewerken",
"delete_confirm": "Deze identiteit verwijderen? Dit kan niet ongedaan worden gemaakt.",
"cannot_delete": "Deze identiteit kan niet worden verwijderd",
"primary_identity": "Primair",
"no_identities": "Geen identiteiten gevonden",
"display": {
"reply_to": "Antwoord naar:",
"bcc": "BCC:",
"signature": "Handtekening:",
"preview": "Voorbeeld:"
},
"validation_errors": {
"invalid_emails": "Ongeldige e-mails: {emails}",
"unknown_error": "Onbekende fout"
},
"form": {
"name_label": "Weergavenaam",
"name_placeholder": "bijv. Werk-e-mail, Persoonlijk",
"name_required": "Naam is vereist",
"email_label": "E-mailadres",
"email_placeholder": "jouw.email@voorbeeld.nl",
"email_required": "E-mail is vereist",
"email_invalid": "Voer een geldig e-mailadres in",
"email_immutable": "E-mailadres kan niet worden gewijzigd na aanmaak",
"reply_to_label": "Antwoord naar (optioneel)",
"reply_to_placeholder": "ander@email.nl",
"bcc_label": "Automatische BCC (optioneel)",
"bcc_placeholder": "archief@email.nl",
"text_signature_label": "Teksthandtekening",
"html_signature_label": "HTML-handtekening",
"save": "Identiteit opslaan",
"cancel": "Annuleren",
"creating": "Aanmaken...",
"updating": "Bijwerken..."
},
"sub_address": {
"button_tooltip": "Sub-adres gebruiken",
"popover_title": "Sub-adrestag toevoegen",
"tag_input_placeholder": "Voer tag in (bijv. winkelen)",
"preview_label": "Voorbeeld:",
"recent_tags": "Recente tags",
"suggested_tags": "Voorgesteld",
"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",
"validation": {
"empty": "Tag mag niet leeg zijn",
"too_long": "Tag mag maximaal {max} tekens bevatten",
"invalid_chars": "Tag mag alleen letters, cijfers en streepjes bevatten"
}
},
"badge": {
"sent_via": "via",
"sub_address_tag": "Verzonden met sub-adres: {tag}",
"identity_name": "Verzonden met identiteit: {name}",
"identity_short": "via {name}",
"subaddress_tag": "+{tag}"
}
}
}
+746
View File
@@ -0,0 +1,746 @@
{
"login": {
"title": "Webmail",
"username_label": "E-mail",
"username_placeholder": "usuario@exemplo.com",
"password_label": "Senha",
"password_placeholder": "Digite sua senha",
"sign_in": "Entrar",
"signing_in": "Entrando...",
"loading": "Carregando...",
"error": {
"invalid_credentials": "E-mail ou senha inválidos",
"connection_failed": "Falha ao conectar com o servidor",
"generic": "Ocorreu um erro. Por favor, tente novamente."
},
"config_error": {
"title": "Erro de Configuração",
"fetch_failed": "Não foi possível carregar a configuração do aplicativo. Por favor, tente novamente mais tarde.",
"server_not_configured": "O servidor de e-mail não foi configurado. Por favor, contate seu administrador."
},
"remove_from_history": "Remover do histórico"
},
"sidebar": {
"close": "Fechar",
"compose": "Escrever",
"search_placeholder": "Buscar e-mails...",
"storage": "Armazenamento",
"sign_out": "Sair",
"settings": "Configurações",
"loading_mailboxes": "Carregando caixas de entrada...",
"push_connected": "Atualizações em tempo real ativas",
"push_disconnected": "Atualizações em tempo real inativas",
"theme": {
"light": "Modo claro",
"dark": "Modo escuro",
"system": "Tema do sistema"
},
"language": {
"title": "Idioma"
},
"mailboxes": {
"inbox": "Caixa de Entrada",
"sent": "Enviados",
"drafts": "Rascunhos",
"trash": "Lixeira",
"archive": "Arquivo",
"starred": "Com Estrela",
"all_mail": "Todos os E-mails",
"spam": "Spam",
"important": "Importante"
},
"expand": "Expandir",
"collapse": "Recolher",
"expand_tooltip": "Expandir",
"collapse_tooltip": "Recolher",
"mobile": {
"search": "Buscar",
"compose": "Escrever",
"go_back": "Voltar"
},
"clear_search": "Limpar busca"
},
"email_list": {
"no_emails": "Nenhum e-mail",
"no_emails_description": "Comece escrevendo um novo e-mail",
"loading": "Carregando e-mails...",
"unread": "não lido",
"to_me": "Para mim",
"to_recipients": "Para {{count}} destinatários",
"and_others": "e {{count}} outros",
"draft": "Rascunho",
"starred": "Com Estrela",
"conversations_count": "{count} de {total} conversas",
"conversations_count_plus": "{count}+ conversas",
"conversations_count_simple": "{count} conversas",
"no_conversations": "Nenhuma conversa",
"loading_more": "Carregando mais e-mails...",
"no_more_emails": "Não há mais e-mails para carregar",
"batch_actions": {
"mark_read": "Marcar como lido",
"mark_unread": "Marcar como não lido",
"delete": "Excluir",
"clear_selection": "Limpar seleção"
}
},
"email_viewer": {
"no_email_selected": "Nenhum e-mail selecionado",
"no_email_description": "Selecione um e-mail da lista para visualizá-lo aqui",
"no_conversation_selected": "Nenhuma conversa selecionada",
"no_conversation_description": "Escolha uma conversa da lista para lê-la aqui",
"no_subject": "(Sem Assunto)",
"loading_email": "Carregando e-mail...",
"loading": "Carregando...",
"reply": "Responder",
"reply_all": "Responder a Todos",
"forward": "Encaminhar",
"delete": "Excluir",
"archive": "Arquivar",
"star": "Adicionar Estrela",
"unstar": "Remover Estrela",
"mark_unread": "Marcar como não lido",
"mark_read": "Marcar como lido",
"print": "Imprimir",
"view_source": "Ver código-fonte",
"email_source": "Código-fonte do E-mail",
"copy_source": "Copiar para a área de transferência",
"source_copied": "Código-fonte copiado para a área de transferência",
"attachments": "Anexos",
"important": "Importante",
"download": "Baixar",
"from": "De",
"to": "Para",
"cc": "CC",
"bcc": "CCO",
"date": "Data",
"subject": "Assunto",
"show_details": "Mostrar detalhes",
"hide_details": "Ocultar detalhes",
"external_content_warning": "Imagens e conteúdo externo foram bloqueados",
"load_external_content": "Carregar imagens",
"trust_sender": "Sempre confiar neste remetente",
"back_to_list": "Voltar para a lista",
"message_details": "Detalhes da Mensagem",
"more_reply_options": "Mais opções de resposta",
"set_color": "Definir cor",
"more_actions": "Mais ações",
"remove_color": "Remover cor",
"more_count": "+{count} mais",
"characters_count": "{count} caracteres",
"quick_reply_placeholder": "Escreva uma resposta rápida...",
"more_options": "Mais opções",
"sending": "Enviando...",
"security_authentication": "Segurança & Autenticação",
"technical_details": "Detalhes Técnicos",
"message_id_label": "Message-ID:",
"reply_to_label": "Responder para:",
"delivery_time_label": "Horário de entrega:",
"conversation_part_label": "Parte da conversa:",
"previous_messages": "{count} mensagem anterior",
"previous_messages_plural": "{count} mensagens anteriores",
"time": {
"day": "dia",
"days": "dias",
"hour": "hora",
"hours": "horas",
"minute": "minuto",
"minutes": "minutos"
},
"unknown_sender": "Desconhecido",
"recipient_me": "eu",
"recipient_and_others": "{name} e {count} outros",
"recipient_to_prefix": "Para:",
"authentication": {
"title": "Autenticação",
"status": {
"verified": "Verificado",
"warning": "Aviso",
"none": "Não autenticado"
},
"spf": {
"pass": "SPF Aprovado",
"fail": "SPF Falhou",
"none": "Sem SPF"
},
"dkim": {
"pass": "DKIM Válido",
"fail": "DKIM Inválido",
"none": "Sem DKIM"
},
"dmarc": {
"pass": "DMARC Aprovado",
"fail": "DMARC Falhou",
"none": "Sem DMARC"
},
"spam_score": "Pontuação de Spam"
},
"headers": {
"routing": "Roteamento",
"received": "Recebido",
"message_id": "ID da Mensagem",
"list_info": "Informações da Lista"
},
"color_tag": {
"title": "Etiqueta de Cor",
"red": "Vermelho",
"orange": "Laranja",
"yellow": "Amarelo",
"green": "Verde",
"blue": "Azul",
"purple": "Roxo",
"pink": "Rosa",
"none": "Nenhuma"
},
"tooltips": {
"reply": "Responder",
"archive": "Arquivar",
"delete": "Excluir"
},
"spam": {
"button_title": "Reportar spam",
"not_spam_title": "Marcar como legítimo",
"toast_success": "Movido para Spam",
"toast_batch": "{count} e-mails movidos para Spam",
"toast_undo": "Desfazer",
"toast_not_spam_success": "Movido para Caixa de Entrada",
"toast_not_spam_batch": "{count} e-mails movidos para Caixa de Entrada",
"error": "Falha ao reportar spam",
"error_not_spam": "Falha ao restaurar e-mail"
},
"unsubscribe_banner": {
"label": "Newsletter",
"button": "Cancelar inscrição",
"confirm_title": "Cancelar inscrição deste remetente?",
"confirm_button": "Confirmar",
"cancel": "Cancelar",
"success_http": "Página de cancelamento aberta em nova aba",
"success_mailto": "Solicitação de cancelamento enviada para seu cliente de e-mail",
"error": "Não foi possível cancelar a inscrição",
"dismiss": "Dispensar"
}
},
"email_composer": {
"new_message": "Nova Mensagem",
"reply": "Responder",
"reply_all": "Responder a Todos",
"forward": "Encaminhar",
"reply_to": "Responder",
"reply_all_to": "Responder a Todos",
"forward_message": "Encaminhar",
"from": "De",
"to": "Para",
"cc": "CC",
"bcc": "CCO",
"subject": "Assunto",
"body_placeholder": "Escreva sua mensagem...",
"send": "Enviar",
"cancel": "Cancelar",
"attach": "Anexar",
"discard": "Descartar",
"discard_draft_confirm": "Você tem alterações não salvas. Deseja descartar este rascunho?",
"saving": "Salvando...",
"draft_saved": "Rascunho salvo",
"save_failed": "Falha ao salvar",
"to_placeholder": "Endereços de e-mail dos destinatários (separados por vírgula)",
"cc_placeholder": "Destinatários CC (separados por vírgula)",
"bcc_placeholder": "Destinatários CCO (separados por vírgula)",
"subject_placeholder": "Assunto",
"cc_label": "CC:",
"bcc_label": "CCO:",
"subject_label": "Assunto:",
"file_size_kb": "KB",
"prefix": {
"forward": "Enc:",
"reply": "Re:"
},
"no_subject": "(Sem Assunto)",
"unknown_sender": "Desconhecido",
"quote": {
"reply_header": "Em {{date}}, {{sender}} escreveu:",
"forward_header": "---------- Mensagem encaminhada ----------",
"from": "De: {{sender}}",
"date": "Data: {{date}}",
"subject": "Assunto: {{subject}}",
"to": "Para: {{recipients}}"
},
"remove_sub_address": "Remover sub-endereço"
},
"common": {
"loading": "Carregando...",
"error": "Erro",
"success": "Sucesso",
"cancel": "Cancelar",
"save": "Salvar",
"delete": "Excluir",
"edit": "Editar",
"close": "Fechar",
"search": "Buscar",
"refresh": "Atualizar",
"settings": "Configurações",
"help": "Ajuda",
"logout": "Sair",
"yes": "Sim",
"no": "Não",
"unknown": "Desconhecido",
"app_title": "Webmail"
},
"notifications": {
"email_sent": "E-mail enviado com sucesso",
"email_deleted": "E-mail excluído",
"email_archived": "E-mail arquivado",
"email_starred": "E-mail marcado com estrela",
"email_unstarred": "Estrela removida do e-mail",
"email_marked_read": "E-mail marcado como lido",
"email_marked_unread": "E-mail marcado como não lido",
"copied_to_clipboard": "Copiado para a área de transferência",
"source_copied": "Código-fonte copiado para a área de transferência",
"error_sending": "Falha ao enviar e-mail",
"error_deleting": "Falha ao excluir e-mail",
"error_loading": "Falha ao carregar e-mails",
"new_email": "Novo e-mail",
"new_email_from": "De {sender}",
"click_to_view": "Clique para visualizar",
"email_moved": "E-mail movido",
"emails_moved": "{count} e-mails movidos",
"moved_to_mailbox": "Movido para {mailbox}",
"move_failed": "Falha ao mover",
"move_error": "Não foi possível mover os e-mails para a pasta selecionada",
"identity_created": "Identidade criada com sucesso",
"identity_updated": "Identidade atualizada com sucesso",
"identity_deleted": "Identidade excluída",
"identity_create_failed": "Falha ao criar identidade: {{error}}",
"identity_update_failed": "Falha ao atualizar identidade: {{error}}",
"identity_delete_failed": "Falha ao excluir identidade: {{error}}",
"identity_unauthorized": "Você não está autorizado a enviar deste endereço de e-mail",
"identity_not_found": "Identidade não encontrada"
},
"date": {
"today": "Hoje",
"yesterday": "Ontem",
"this_week": "Esta semana",
"last_week": "Semana passada",
"this_month": "Este mês",
"older": "Mais antigos",
"just_now": "Agora mesmo",
"minutes_ago": "{{count}} minuto atrás",
"minutes_ago_plural": "{{count}} minutos atrás",
"hours_ago": "{{count}} hora atrás",
"hours_ago_plural": "{{count}} horas atrás",
"days_ago": "{{count}} dia atrás",
"days_ago_plural": "{{count}} dias atrás"
},
"language": {
"title": "Idioma",
"english": "English",
"french": "Français",
"japanese": "日本語",
"spanish": "Español",
"italian": "Italiano",
"german": "Deutsch",
"dutch": "Nederlands",
"portuguese": "Português",
"select_language": "Selecionar idioma",
"switch_to_english": "Mudar para Inglês",
"switch_to_french": "Mudar para Francês",
"switch_to_japanese": "Mudar para Japonês",
"switch_to_spanish": "Mudar para Espanhol",
"switch_to_italian": "Mudar para Italiano",
"switch_to_german": "Mudar para Alemão",
"switch_to_dutch": "Mudar para Holandês",
"switch_to_portuguese": "Mudar para Português",
"switching": "Alterando idioma..."
},
"settings": {
"title": "Configurações",
"back_to_mail": "Voltar para E-mails",
"save_success": "Configurações salvas com sucesso",
"import_success": "Configurações importadas com sucesso",
"import_error": "Falha ao importar configurações",
"reset_confirm": "Tem certeza de que deseja redefinir todas as configurações para os padrões?",
"tabs": {
"appearance": "Aparência",
"language": "Idioma e Região",
"email": "Comportamento de E-mail",
"composer": "Editor",
"privacy": "Privacidade e Segurança",
"account": "Conta",
"identities": "Identidades",
"advanced": "Avançado"
},
"appearance": {
"title": "Aparência",
"description": "Personalize a aparência do seu webmail",
"theme": {
"label": "Tema",
"description": "Escolha seu esquema de cores preferido",
"light": "Claro",
"dark": "Escuro",
"system": "Sistema"
},
"language": {
"label": "Idioma",
"description": "Escolha seu idioma preferido"
},
"font_size": {
"label": "Tamanho da Fonte",
"description": "Ajuste o tamanho do texto para melhor legibilidade",
"small": "Pequeno",
"medium": "Médio",
"large": "Grande"
},
"list_density": {
"label": "Densidade da Lista",
"description": "Controle o espaçamento nas listas de e-mail",
"compact": "Compacto",
"regular": "Regular",
"comfortable": "Confortável"
},
"animations": {
"label": "Habilitar Animações",
"description": "Mostrar transições e efeitos suaves"
}
},
"language_region": {
"title": "Idioma e Região",
"description": "Configure preferências de idioma e região",
"language": {
"label": "Idioma",
"description": "Escolha seu idioma preferido",
"english": "English",
"french": "Français"
},
"date_format": {
"label": "Formato de Data",
"description": "Como as datas devem ser exibidas",
"regional": "Regional",
"iso": "ISO 8601",
"custom": "Personalizado"
},
"time_format": {
"label": "Formato de Hora",
"description": "Escolha entre relógio de 12 ou 24 horas",
"12h": "12 horas",
"24h": "24 horas"
},
"first_day": {
"label": "Primeiro Dia da Semana",
"description": "Começar a semana no domingo ou segunda-feira",
"sunday": "Domingo",
"monday": "Segunda-feira"
}
},
"email_behavior": {
"title": "Comportamento de E-mail",
"description": "Configure como os e-mails são manipulados",
"mark_read": {
"label": "Marcar como Lido",
"description": "Quando marcar e-mails como lidos ao abri-los",
"instant": "Instantaneamente",
"delay_3s": "Após 3 segundos",
"delay_5s": "Após 5 segundos",
"never": "Nunca"
},
"delete_action": {
"label": "Ação de Exclusão",
"description": "O que acontece quando você exclui um e-mail",
"trash": "Mover para Lixeira",
"permanent": "Excluir Permanentemente"
},
"show_preview": {
"label": "Mostrar Texto de Visualização",
"description": "Exibir visualização do e-mail na lista"
},
"emails_per_page": {
"label": "E-mails Por Página",
"description": "Número de e-mails a carregar de uma vez",
"25": "25 e-mails",
"50": "50 e-mails",
"100": "100 e-mails"
},
"external_content": {
"label": "Conteúdo Externo",
"description": "Como lidar com imagens e conteúdo externo",
"ask": "Sempre perguntar",
"block": "Sempre bloquear",
"allow": "Sempre permitir"
},
"trusted_senders": {
"label": "Remetentes Confiáveis",
"description": "Gerencie remetentes cujas imagens são carregadas automaticamente",
"count_zero": "Nenhum",
"count_one": "1 remetente",
"count_other": "{count} remetentes",
"modal_title": "Remetentes Confiáveis",
"empty_title": "Nenhum remetente confiável ainda",
"empty_description": "Ao visualizar um e-mail com imagens bloqueadas, clique em \"Sempre confiar neste remetente\" para adicioná-lo aqui.",
"add_manually": "Adicionar remetente manualmente",
"add_button": "Adicionar",
"add_placeholder": "Digite o endereço de e-mail",
"search_placeholder": "Buscar remetentes...",
"no_results": "Nenhum remetente corresponde à sua busca",
"remove": "Remover",
"close": "Fechar",
"invalid_email": "Por favor, digite um endereço de e-mail válido",
"already_added": "Este remetente já é confiável"
}
},
"composer": {
"title": "Editor",
"description": "Configure as configurações de composição de e-mail",
"autosave": {
"label": "Intervalo de Salvamento Automático",
"description": "Com que frequência salvar rascunhos automaticamente",
"30s": "A cada 30 segundos",
"1m": "A cada minuto",
"2m": "A cada 2 minutos",
"5m": "A cada 5 minutos"
},
"send_confirmation": {
"label": "Confirmação de Envio",
"description": "Pedir confirmação antes de enviar e-mails"
},
"default_reply": {
"label": "Modo de Resposta Padrão",
"description": "Ação padrão ao clicar em responder",
"reply": "Responder",
"reply_all": "Responder a Todos"
}
},
"privacy": {
"title": "Privacidade e Segurança",
"description": "Gerencie suas configurações de privacidade e segurança",
"external_images": {
"label": "Bloquear Imagens Externas",
"description": "Prevenir rastreamento através de imagens externas"
},
"session_timeout": {
"label": "Tempo Limite de Sessão",
"description": "Sair automaticamente após inatividade",
"never": "Nunca",
"30m": "30 minutos",
"1h": "1 hora",
"4h": "4 horas"
},
"clear_cache": {
"label": "Limpar Cache",
"description": "Remover dados em cache e arquivos temporários",
"button": "Limpar Cache",
"confirm": "Tem certeza de que deseja limpar o cache?",
"success": "Cache limpo com sucesso"
}
},
"account": {
"title": "Conta",
"description": "Visualize as informações da sua conta",
"email": {
"label": "Endereço de E-mail",
"value": "{{email}}"
},
"server": {
"label": "Servidor JMAP",
"value": "{{server}}"
},
"storage": {
"label": "Uso de Armazenamento",
"used": "{{used}} de {{total}} usado",
"percentage": "{{percent}}% usado"
},
"last_sync": {
"label": "Última Sincronização",
"value": "{{time}}"
}
},
"identities": {
"title": "Identidades de Envio",
"description": "Gerencie endereços de e-mail que você pode usar para enviar",
"identities_count": {
"label": "Suas Identidades",
"description": "Endereços de e-mail configurados para envio",
"count_zero": "Nenhuma identidade",
"count_one": "1 identidade",
"count_other": "{{count}} identidades"
},
"manage": "Gerenciar Identidades",
"sub_addressing": {
"label": "Sub-Endereçamento",
"description": "Use tags como usuario+tag@dominio.com para organizar e-mails recebidos",
"learn_more": "Saiba Mais"
}
},
"advanced": {
"title": "Avançado",
"description": "Opções avançadas e configurações de desenvolvedor",
"debug_mode": {
"label": "Modo de Depuração",
"description": "Habilitar registro detalhado para solução de problemas"
},
"keyboard_shortcuts": {
"label": "Atalhos de Teclado",
"description": "Visualizar atalhos de teclado disponíveis",
"button": "Ver Atalhos"
},
"reset_settings": {
"label": "Redefinir Configurações",
"description": "Restaurar todas as configurações para valores padrão",
"button": "Redefinir para Padrões"
},
"export_settings": {
"label": "Exportar Configurações",
"description": "Baixar suas configurações como JSON",
"button": "Exportar"
},
"import_settings": {
"label": "Importar Configurações",
"description": "Fazer upload de configurações de arquivo JSON",
"button": "Importar"
}
}
},
"errors": {
"page_error_title": "Algo deu errado",
"page_error_description": "Encontramos um erro inesperado. Por favor, tente novamente ou volte para a página inicial.",
"sidebar_error": "Não foi possível carregar as caixas de entrada",
"email_list_error": "Não foi possível carregar os e-mails",
"viewer_error_title": "Não foi possível exibir o e-mail",
"viewer_error_description": "Houve um problema ao renderizar este e-mail. Ele pode conter conteúdo não suportado.",
"composer_error": "Não foi possível carregar o editor",
"settings_error_title": "Configurações indisponíveis",
"settings_error_description": "Não foi possível carregar as configurações. Suas preferências podem não ser salvas.",
"try_again": "Tentar novamente",
"reload": "Recarregar",
"reload_emails": "Recarregar e-mails",
"reload_settings": "Recarregar configurações",
"retry": "Tentar novamente",
"go_home": "Ir para caixa de entrada"
},
"context_menu": {
"reply": "Responder",
"reply_all": "Responder a Todos",
"forward": "Encaminhar",
"mark_read": "Marcar como Lido",
"mark_unread": "Marcar como Não Lido",
"star": "Adicionar Estrela",
"unstar": "Remover Estrela",
"move_to": "Mover para...",
"archive": "Arquivar",
"delete": "Excluir",
"mark_as_spam": "Reportar spam",
"not_spam": "Não é spam",
"color_tag": "Etiqueta de Cor",
"remove_color": "Remover Cor",
"items_selected": "{{count}} e-mails selecionados"
},
"shortcuts": {
"title": "Atalhos de Teclado",
"tip": "Pressione ? a qualquer momento para mostrar esta ajuda",
"sections": {
"navigation": "Navegação",
"actions": "Ações de E-mail",
"global": "Global",
"threads": "Conversas"
},
"navigation": {
"next_email": "Próximo e-mail",
"previous_email": "E-mail anterior",
"open_email": "Abrir e-mail",
"close_email": "Fechar / Desselecionar"
},
"actions": {
"reply": "Responder",
"reply_all": "Responder a todos",
"forward": "Encaminhar",
"star": "Alternar estrela",
"archive": "Arquivar",
"delete": "Excluir",
"mark_unread": "Marcar como não lido",
"mark_read": "Marcar como lido",
"toggle_spam": "Reportar spam / Não é spam"
},
"global": {
"compose": "Escrever novo e-mail",
"search": "Focar busca",
"help": "Mostrar atalhos",
"refresh": "Atualizar e-mails",
"select_all": "Selecionar todos"
},
"threads": {
"expand_collapse": "Expandir/recolher conversa"
}
},
"threads": {
"messages_one": "{count} mensagem",
"messages_other": "{count} mensagens",
"expand": "Expandir conversa",
"collapse": "Recolher conversa",
"loading": "Carregando conversa...",
"mark_read": "Marcar conversa como lida",
"mark_unread": "Marcar conversa como não lida",
"archive": "Arquivar conversa",
"delete": "Excluir conversa",
"star": "Adicionar estrela à conversa",
"unstar": "Remover estrela da conversa"
},
"identities": {
"modal_title": "Gerenciar Identidades de Envio",
"create_new": "Criar Nova Identidade",
"edit_identity": "Editar Identidade",
"delete_confirm": "Excluir esta identidade? Isso não pode ser desfeito.",
"cannot_delete": "Esta identidade não pode ser excluída",
"primary_identity": "Principal",
"no_identities": "Nenhuma identidade encontrada",
"display": {
"reply_to": "Responder para:",
"bcc": "CCO:",
"signature": "Assinatura:",
"preview": "Visualização:"
},
"validation_errors": {
"invalid_emails": "E-mails inválidos: {emails}",
"unknown_error": "Erro desconhecido"
},
"form": {
"name_label": "Nome de Exibição",
"name_placeholder": "ex: E-mail do Trabalho, Pessoal",
"name_required": "Nome é obrigatório",
"email_label": "Endereço de E-mail",
"email_placeholder": "seu.email@exemplo.com",
"email_required": "E-mail é obrigatório",
"email_invalid": "Por favor, digite um endereço de e-mail válido",
"email_immutable": "O endereço de e-mail não pode ser alterado após a criação",
"reply_to_label": "Responder para (opcional)",
"reply_to_placeholder": "diferente@email.com",
"bcc_label": "CCO Automático (opcional)",
"bcc_placeholder": "arquivo@email.com",
"text_signature_label": "Assinatura de Texto",
"html_signature_label": "Assinatura HTML",
"save": "Salvar Identidade",
"cancel": "Cancelar",
"creating": "Criando...",
"updating": "Atualizando..."
},
"sub_address": {
"button_tooltip": "Usar sub-endereço",
"popover_title": "Adicionar Tag de Sub-Endereço",
"tag_input_placeholder": "Digite a tag (ex: compras)",
"preview_label": "Visualização:",
"recent_tags": "Tags Recentes",
"suggested_tags": "Sugeridas",
"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",
"validation": {
"empty": "A tag não pode estar vazia",
"too_long": "A tag deve ter no máximo {max} caracteres",
"invalid_chars": "A tag deve conter apenas letras, números e hífens"
}
},
"badge": {
"sent_via": "via",
"sub_address_tag": "Enviado usando sub-endereço: {tag}",
"identity_name": "Enviado usando identidade: {name}",
"identity_short": "via {name}",
"subaddress_tag": "+{tag}"
}
}
}
+2382 -160
View File
File diff suppressed because it is too large Load Diff
+14 -7
View File
@@ -34,31 +34,38 @@
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.2.7", "dompurify": "^3.3.1",
"jmap-jam": "^0.13.1", "jmap-jam": "^0.13.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"next": "^16.0.8", "next": "^16.0.8",
"next-auth": "^4.24.11",
"next-intl": "^4.5.8", "next-intl": "^4.5.8",
"react": "^19.2.1", "react": "^19.2.1",
"react-dom": "^19.2.1", "react-dom": "^19.2.1",
"tailwind-merge": "^3.3.1", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@types/node": "^22", "@types/node": "^22",
"@types/react": "^19", "@types/react": "^19.2.7",
"@types/react-dom": "^19", "@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.49.0", "@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.49.0", "@typescript-eslint/parser": "^8.49.0",
"@vitejs/plugin-react": "^5.1.2",
"@vitest/ui": "^4.0.16",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-next": "^16.0.8", "eslint-config-next": "^16.0.8",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"globals": "^16.5.0", "globals": "^17.0.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^27.4.0",
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"typescript": "^5" "typescript": "^5.9.3",
"vitest": "^4.0.16"
} }
} }
+7
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { JMAPClient } from '@/lib/jmap/client'; import { JMAPClient } from '@/lib/jmap/client';
import { useEmailStore } from './email-store'; import { useEmailStore } from './email-store';
import { useIdentityStore } from './identity-store';
import type { Identity } from '@/lib/jmap/types'; import type { Identity } from '@/lib/jmap/types';
interface AuthState { interface AuthState {
@@ -46,6 +47,9 @@ export const useAuthStore = create<AuthState>()(
const identities = await client.getIdentities(); const identities = await client.getIdentities();
const primaryIdentity = identities.length > 0 ? identities[0] : null; const primaryIdentity = identities.length > 0 ? identities[0] : null;
// Sync identities to identity store
useIdentityStore.getState().setIdentities(identities);
// Success - save state (but NOT the password) // Success - save state (but NOT the password)
set({ set({
isAuthenticated: true, isAuthenticated: true,
@@ -117,6 +121,9 @@ export const useAuthStore = create<AuthState>()(
searchQuery: "", searchQuery: "",
quota: null, quota: null,
}); });
// Clear identity store state
useIdentityStore.getState().clearIdentities();
}, },
checkAuth: async () => { checkAuth: async () => {
+142 -5
View File
@@ -46,7 +46,7 @@ interface EmailStore {
loadMoreEmails: (client: JMAPClient) => Promise<void>; loadMoreEmails: (client: JMAPClient) => Promise<void>;
fetchEmailContent: (client: JMAPClient, emailId: string) => Promise<Email | null>; fetchEmailContent: (client: JMAPClient, emailId: string) => Promise<Email | null>;
fetchQuota: (client: JMAPClient) => Promise<void>; fetchQuota: (client: JMAPClient) => Promise<void>;
sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], draftId?: string, fromEmail?: string, identityId?: string) => Promise<void>; sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string) => Promise<void>;
deleteEmail: (client: JMAPClient, emailId: string) => Promise<void>; deleteEmail: (client: JMAPClient, emailId: string) => Promise<void>;
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>; markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>;
moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise<void>; moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise<void>;
@@ -58,6 +58,13 @@ interface EmailStore {
batchDelete: (client: JMAPClient) => Promise<void>; batchDelete: (client: JMAPClient) => Promise<void>;
batchMoveToMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>; batchMoveToMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>;
// Spam operations
spamUndoCache: Map<string, { emailId: string; originalMailboxId: string; accountId?: string }>;
markAsSpam: (client: JMAPClient, emailId: string) => Promise<void>;
undoSpam: (client: JMAPClient, emailId: string) => Promise<void>;
batchMarkAsSpam: (client: JMAPClient, emailIds: string[]) => Promise<void>;
batchUndoSpam: (client: JMAPClient, emailIds: string[]) => Promise<void>;
// Push notification handlers // Push notification handlers
setPushConnected: (connected: boolean) => void; setPushConnected: (connected: boolean) => void;
handleStateChange: (change: StateChange, client: JMAPClient) => Promise<void>; handleStateChange: (change: StateChange, client: JMAPClient) => Promise<void>;
@@ -99,6 +106,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
threadEmailsCache: new Map(), threadEmailsCache: new Map(),
isLoadingThread: null, isLoadingThread: null,
// Spam undo cache
spamUndoCache: new Map(),
setEmails: (emails) => set({ emails }), setEmails: (emails) => set({ emails }),
setMailboxes: (mailboxes) => set({ mailboxes }), setMailboxes: (mailboxes) => set({ mailboxes }),
selectEmail: (email) => set({ selectedEmail: email }), selectEmail: (email) => set({ selectedEmail: email }),
@@ -282,12 +292,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
} }
}, },
sendEmail: async (client, to, subject, body, cc, bcc, draftId, fromEmail, identityId) => { sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId) => {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
await client.sendEmail(to, subject, body, cc, bcc, draftId, fromEmail, identityId); await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId);
// Refresh emails after sending // Refresh handled by UI layer for immediate feedback
await get().fetchEmails(client);
set({ isLoading: false }); set({ isLoading: false });
} catch (error) { } catch (error) {
set({ set({
@@ -763,6 +772,134 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
} }
}, },
// Spam operations
markAsSpam: async (client, emailId) => {
const { selectedMailbox, mailboxes, emails } = get();
const email = emails.find(e => e.id === emailId);
if (!email) return;
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
if (!currentMailbox) return;
get().spamUndoCache.set(emailId, {
emailId,
originalMailboxId: currentMailbox.originalId || currentMailbox.id,
accountId: currentMailbox.accountId,
});
try {
await client.markAsSpam(emailId, currentMailbox.accountId);
set(state => ({
emails: state.emails.filter(e => e.id !== emailId),
selectedEmail: state.selectedEmail?.id === emailId ? null : state.selectedEmail,
}));
const currentIndex = emails.findIndex(e => e.id === emailId);
if (currentIndex >= 0 && currentIndex < emails.length - 1) {
set({ selectedEmail: emails[currentIndex + 1] });
}
} catch (error) {
console.error('Failed to mark as spam:', error);
throw error;
}
},
undoSpam: async (client, emailId) => {
const { mailboxes, selectedMailbox } = get();
// Try cache first (preserves exact original mailbox for toast undo)
const cachedData = get().spamUndoCache.get(emailId);
let targetMailboxId: string;
let accountId: string | undefined;
if (cachedData) {
// Use cached original mailbox (more accurate for immediate undo)
targetMailboxId = cachedData.originalMailboxId;
accountId = cachedData.accountId;
get().spamUndoCache.delete(emailId);
} else {
// Fall back to finding Inbox (generic "not spam" button/menu)
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
accountId = currentMailbox?.accountId;
// Find inbox in same account
const inboxMailbox = mailboxes.find(m =>
m.role === 'inbox' &&
(accountId ? m.accountId === accountId : !m.accountId)
);
if (!inboxMailbox) {
throw new Error('Inbox not found');
}
targetMailboxId = inboxMailbox.id;
}
try {
await client.undoSpam(emailId, targetMailboxId, accountId);
await get().fetchEmails(client, selectedMailbox);
} catch (error) {
console.error('Failed to restore email:', error);
throw error;
}
},
batchMarkAsSpam: async (client, emailIds) => {
const { selectedMailbox, mailboxes } = get();
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
if (!currentMailbox) return;
try {
for (const emailId of emailIds) {
await client.markAsSpam(emailId, currentMailbox.accountId);
}
set(state => ({
emails: state.emails.filter(e => !emailIds.includes(e.id)),
selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail,
selectedEmailIds: new Set(),
}));
} catch (error) {
console.error('Failed to batch mark as spam:', error);
throw error;
}
},
batchUndoSpam: async (client: JMAPClient, emailIds: string[]) => {
const { mailboxes, selectedMailbox } = get();
// Find inbox (batch operations don't preserve original mailboxes)
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const accountId = currentMailbox?.accountId;
const inboxMailbox = mailboxes.find(m =>
m.role === 'inbox' &&
(accountId ? m.accountId === accountId : !m.accountId)
);
if (!inboxMailbox) {
throw new Error('Inbox not found');
}
try {
for (const emailId of emailIds) {
await client.undoSpam(emailId, inboxMailbox.id, accountId);
}
set(state => ({
emails: state.emails.filter(e => !emailIds.includes(e.id)),
selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail,
selectedEmailIds: new Set(),
}));
} catch (error) {
console.error('Failed to batch restore emails:', error);
throw error;
}
},
// Push notification handlers // Push notification handlers
setPushConnected: (connected) => { setPushConnected: (connected) => {
set({ isPushConnected: connected }); set({ isPushConnected: connected });
+127
View File
@@ -0,0 +1,127 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { Identity } from '@/lib/jmap/types';
// Constants for sub-addressing limits
const MAX_RECENT_TAGS = 10;
const MAX_DOMAIN_SUGGESTIONS = 5;
interface SubAddressState {
recentTags: string[];
tagSuggestions: Record<string, string[]>;
}
interface IdentityStore {
// Identity state (from server)
identities: Identity[];
selectedIdentityId: string | null;
isLoading: boolean;
error: string | null;
// Sub-addressing state (persisted locally)
subAddress: SubAddressState;
// Actions - Identity CRUD
setIdentities: (identities: Identity[]) => void;
addIdentity: (identity: Identity) => void;
updateIdentityLocal: (identityId: string, updates: Partial<Identity>) => void;
removeIdentity: (identityId: string) => void;
selectIdentity: (identityId: string | null) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
clearIdentities: () => void;
// Sub-addressing actions
addRecentTag: (tag: string) => void;
addTagSuggestion: (domain: string, tag: string) => void;
getTagSuggestionsForDomain: (domain: string) => string[];
clearRecentTags: () => void;
}
export const useIdentityStore = create<IdentityStore>()(
persist(
(set, get) => ({
identities: [],
selectedIdentityId: null,
isLoading: false,
error: null,
subAddress: {
recentTags: [],
tagSuggestions: {},
},
setIdentities: (identities) => set({ identities }),
addIdentity: (identity) => set((state) => ({
identities: [...state.identities, identity]
})),
updateIdentityLocal: (identityId, updates) => set((state) => ({
identities: state.identities.map(id =>
id.id === identityId ? { ...id, ...updates } : id
)
})),
removeIdentity: (identityId) => set((state) => ({
identities: state.identities.filter(id => id.id !== identityId),
selectedIdentityId: state.selectedIdentityId === identityId
? null
: state.selectedIdentityId
})),
selectIdentity: (identityId) => set({ selectedIdentityId: identityId }),
setLoading: (loading) => set({ isLoading: loading }),
setError: (error) => set({ error }),
clearIdentities: () => set({
identities: [],
selectedIdentityId: null,
error: null,
}),
addRecentTag: (tag) => set((state) => {
const recent = [tag, ...state.subAddress.recentTags.filter(t => t !== tag)];
return {
subAddress: {
...state.subAddress,
recentTags: recent.slice(0, MAX_RECENT_TAGS),
}
};
}),
addTagSuggestion: (domain, tag) => set((state) => {
const suggestions = { ...state.subAddress.tagSuggestions };
const existing = suggestions[domain] || [];
if (!existing.includes(tag)) {
suggestions[domain] = [...existing, tag].slice(0, MAX_DOMAIN_SUGGESTIONS);
}
return {
subAddress: {
...state.subAddress,
tagSuggestions: suggestions,
}
};
}),
getTagSuggestionsForDomain: (domain) => {
return get().subAddress.tagSuggestions[domain] || [];
},
clearRecentTags: () => set((state) => ({
subAddress: {
...state.subAddress,
recentTags: [],
}
})),
}),
{
name: 'identity-storage',
// Only persist sub-addressing data, not identities (they're server-side)
partialize: (state) => ({
subAddress: state.subAddress
}),
}
)
);