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:
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +369,7 @@ 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>
|
||||||
|
<div className="flex-1 flex items-center gap-1">
|
||||||
{identities.length > 1 ? (
|
{identities.length > 1 ? (
|
||||||
<select
|
<select
|
||||||
value={selectedIdentityId || primaryIdentity?.id || ''}
|
value={selectedIdentityId || primaryIdentity?.id || ''}
|
||||||
@@ -354,12 +383,42 @@ export function EmailComposer({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-foreground">
|
<span className="text-sm text-foreground flex-1">
|
||||||
|
{subAddressTag ? (
|
||||||
|
<span className="font-mono">
|
||||||
|
{generateSubAddress(primaryIdentity?.email || '', subAddressTag)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{primaryIdentity?.name
|
{primaryIdentity?.name
|
||||||
? `${primaryIdentity.name} <${primaryIdentity.email}>`
|
? `${primaryIdentity.name} <${primaryIdentity.email}>`
|
||||||
: primaryIdentity?.email || ''}
|
: primaryIdentity?.email || ''}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</span>
|
</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">
|
||||||
|
|||||||
@@ -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")}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,23 +898,22 @@ 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">
|
|
||||||
<{sender.email}>
|
|
||||||
</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)}
|
||||||
|
</span>
|
||||||
{email.to.length > 2 && (
|
{email.to.length > 2 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowFullHeaders(!showFullHeaders)}
|
onClick={() => setShowFullHeaders(!showFullHeaders)}
|
||||||
@@ -811,15 +922,15 @@ export function EmailViewer({
|
|||||||
{t('more_count', { count: email.to.length - 2 })}
|
{t('more_count', { count: email.to.length - 2 })}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</span>
|
|
||||||
</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,28 +1250,32 @@ 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">
|
<>
|
||||||
<{sender.email}>
|
<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>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
{/* CC line (mobile - only if present) */}
|
||||||
{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="mt-1 flex items-center gap-1 text-sm">
|
||||||
<span className="text-muted-foreground">CC:</span>
|
<span className="text-muted-foreground">CC:</span>
|
||||||
<span className="text-foreground truncate">
|
<span className="text-foreground truncate">
|
||||||
{email.cc.slice(0, 2).map(r => r.name || r.email).join(", ")}
|
{email.cc.slice(0, 2).map(r => r.name || r.email).join(", ")}
|
||||||
@@ -1171,17 +1286,21 @@ export function EmailViewer({
|
|||||||
</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') ||
|
||||||
|
(shouldShowUnsubBanner && listHeaders?.listUnsubscribe)) && (
|
||||||
|
<div className="border-b border-border bg-muted/30 isolate">
|
||||||
|
<div className="max-w-4xl mx-auto px-6 py-1.5">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center md:justify-center gap-3 isolate">
|
||||||
|
{/* External Content Controls */}
|
||||||
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
|
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
|
||||||
<div className="border-b border-border">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<div className="max-w-4xl mx-auto px-6 py-2 flex items-center justify-center gap-4">
|
|
||||||
{/* Load images button - only in 'ask' mode */}
|
{/* Load images button - only in 'ask' mode */}
|
||||||
{externalContentPolicy === 'ask' && (
|
{externalContentPolicy === 'ask' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setAllowExternalContent(true)}
|
onClick={() => setAllowExternalContent(true)}
|
||||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
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"
|
||||||
>
|
>
|
||||||
<Image className="w-3.5 h-3.5" />
|
<Image className="w-3.5 h-3.5" />
|
||||||
{t('load_external_content')}
|
{t('load_external_content')}
|
||||||
@@ -1189,8 +1308,6 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
{/* Trust sender button - in both 'ask' and 'block' modes */}
|
{/* Trust sender button - in both 'ask' and 'block' modes */}
|
||||||
{email.from?.[0]?.email && (
|
{email.from?.[0]?.email && (
|
||||||
<>
|
|
||||||
{externalContentPolicy === 'ask' && <span className="text-muted-foreground/50">|</span>}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const senderEmail = email.from?.[0]?.email;
|
const senderEmail = email.from?.[0]?.email;
|
||||||
@@ -1199,13 +1316,29 @@ export function EmailViewer({
|
|||||||
setAllowExternalContent(true);
|
setAllowExternalContent(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
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')}
|
{t('trust_sender')}
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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]));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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 (onSuccess) {
|
||||||
|
onSuccess(emailIds.length, mailbox.name);
|
||||||
|
} else {
|
||||||
|
// Fallback for backward compatibility
|
||||||
if (emailIds.length === 1) {
|
if (emailIds.length === 1) {
|
||||||
toast.success("Email moved", `Moved to ${mailbox.name}`);
|
toast.success("Email moved", `Moved to ${mailbox.name}`);
|
||||||
} else {
|
} else {
|
||||||
toast.success("Emails moved", `${emailIds.length} emails moved to ${mailbox.name}`);
|
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);
|
||||||
|
|
||||||
|
// 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");
|
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();
|
||||||
|
|
||||||
|
|||||||
@@ -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:');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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']) {
|
||||||
|
|||||||
@@ -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
@@ -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"]);
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
@@ -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}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2382
-160
File diff suppressed because it is too large
Load Diff
+14
-7
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
|
|||||||
@@ -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
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user