fix: name each message frame after its subject and give it the mail's language

This commit is contained in:
Linus Rath
2026-10-29 17:53:54 +01:00
parent 8b68b549c3
commit c20b9846b9
35 changed files with 335 additions and 35 deletions
@@ -0,0 +1,56 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
// Conversation view: each message frame has its own name and language, and
// the reading settings reach every message body.
const t = Object.assign(
(key: string, params?: Record<string, unknown>) => (params ? `${key} ${JSON.stringify(params)}` : key),
{ raw: (key: string) => key, rich: (key: string) => key, has: () => true },
);
vi.mock('next-intl', () => ({
useTranslations: () => t,
useLocale: () => 'en',
}));
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false }));
vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false }));
const { ThreadConversationView } = await import('@/components/email/thread-conversation-view');
const { useSettingsStore } = await import('@/stores/settings-store');
const { ACCESSIBILITY_DEFAULTS } = await import('@/lib/accessibility-settings');
function mail(id: string, extra: Record<string, unknown> = {}) {
return {
id, threadId: 't1', mailboxIds: { a: true }, keywords: { $seen: true },
from: [{ name: 'Sender', email: 'sender@t.example' }], to: [{ email: 'me@example.org' }],
subject: `Subject ${id}`, receivedAt: '2026-09-26T10:00:00Z', size: 1, preview: 'hello', hasAttachment: false,
htmlBody: [{ partId: '1', type: 'text/html' }], textBody: [], attachments: [],
bodyValues: { '1': { value: '<p>hello <b>there</b></p>' } },
...extra,
};
}
function show(emails: ReturnType<typeof mail>[]) {
const latest = emails[emails.length - 1];
return render(
<ThreadConversationView
thread={{ threadId: 't1', emails, latestEmail: latest } as never}
emails={emails as never}
onBack={() => {}}
/>,
);
}
beforeEach(() => {
useSettingsStore.setState({ ...ACCESSIBILITY_DEFAULTS, fontSize: 'medium' } as never);
});
describe('conversation message frames', () => {
it('are named after their own subject and carry the body language', () => {
const { container } = show([mail('a', { htmlBody: [{ partId: '1', type: 'text/html', language: ['it'] }] })]);
const iframe = container.querySelector('iframe')!;
expect(iframe.getAttribute('title')).toBe('email_viewer.frame_title {"subject":"Subject a"}');
expect(iframe.getAttribute('srcdoc')).toMatch(/<html lang="it"/);
expect(screen.queryByTitle('Email content')).toBeNull();
});
});
@@ -0,0 +1,96 @@
import { render, act, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { clearAllHooks } from '@/lib/plugin-hooks';
// How a received message is shown: the body frame's name and language, and
// the reading settings that reach the body.
const t = Object.assign(
(key: string, params?: Record<string, unknown>) => (params ? `${key} ${JSON.stringify(params)}` : key),
{ raw: (key: string) => key, rich: (key: string) => key, has: () => true },
);
vi.mock('next-intl', () => ({
useTranslations: () => t,
useLocale: () => 'en',
useFormatter: () => ({ dateTime: (d: Date | string) => String(d), relativeTime: (d: Date | string) => String(d), number: (n: number) => String(n) }),
}));
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
useMediaQuery: () => false,
useIsMobile: () => false,
useIsDesktop: () => true,
}));
vi.mock('@/components/tour/tour-provider', () => ({
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
class NoopResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
const { EmailViewer } = await import('../email-viewer');
const { useSettingsStore } = await import('@/stores/settings-store');
const { ACCESSIBILITY_DEFAULTS } = await import('@/lib/accessibility-settings');
const mailboxes = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
] as unknown as Mailbox[];
function message(overrides: Partial<Email> & { html?: string; text?: string } = {}): Email {
const { html, text, ...rest } = overrides;
const bodyValues: Record<string, { value: string }> = {};
if (html !== undefined) bodyValues.h = { value: html };
if (text !== undefined) bodyValues.t = { value: text };
return {
id: 'm1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }],
subject: 'Bonjour', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
htmlBody: html !== undefined ? [{ partId: 'h', type: 'text/html' }] : [],
textBody: text !== undefined ? [{ partId: 't', type: 'text/plain' }] : html !== undefined ? [{ partId: 'h', type: 'text/html' }] : [],
attachments: [],
bodyValues,
...rest,
} as unknown as Email;
}
async function show(email: Email) {
const view = render(
<EmailViewer email={email} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />,
);
await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
return view;
}
beforeEach(() => {
clearAllHooks();
useSettingsStore.setState({ ...ACCESSIBILITY_DEFAULTS, fontSize: 'medium' } as never);
});
describe('message body frame', () => {
it('is named after the subject and carries the mail language', async () => {
const { container } = await show(message({
html: '<html lang="fr"><body><p>Bonjour tout le monde</p></body></html>',
}));
const iframe = container.querySelector('iframe')!;
expect(iframe.getAttribute('title')).toBe('frame_title {"subject":"Bonjour"}');
expect(iframe.getAttribute('srcdoc')).toMatch(/^<!DOCTYPE html>\s*<html lang="fr"/);
});
it('is named after the sender when there is no subject, with no language guessed', async () => {
const { container } = await show(message({ subject: '', html: '<p>Hi there, <b>friend</b></p>' }));
const iframe = container.querySelector('iframe')!;
expect(iframe.getAttribute('title')).toBe('frame_title_from {"sender":"Alice"}');
expect(iframe.getAttribute('srcdoc')).not.toMatch(/<html[^>]*\slang=/);
});
it('marks a plain-text body with its Content-Language', async () => {
await show(message({ text: 'Hallo zusammen', textBody: [{ partId: 't', type: 'text/plain', language: ['de'] }] as never }));
expect(screen.getByText('Hallo zusammen').closest('[lang]')?.getAttribute('lang')).toBe('de');
});
});
+13 -3
View File
@@ -6,6 +6,7 @@ import { attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_ATTACHME
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { TRANSPARENT_BLOCKED_PIXEL, applyNewTabToAnchor, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
import { mailLanguage } from "@/lib/mail-language";
import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { DARK_PENDING_CLASS, applyEmailDarkMode, emailColorScheme, emailDarkModeCss, emailHasNativeDarkMode as htmlHasNativeDarkMode, resolveColorSchemeMedia } from "@/lib/email-dark-mode";
@@ -3153,6 +3154,14 @@ export function EmailViewer({
? emailViewDarkOverride
: (emailAlwaysLightMode ? false : resolvedTheme === 'dark');
// The body's language (Content-Language or the HTML's own lang), so a
// screen reader switches voice for it; none when the mail doesn't say.
const bodyLanguage = useMemo(() => mailLanguage(email, email ? getRenderableHtmlBody(email) : null), [email]);
const frameSender = email?.from?.[0];
const frameTitle = email?.subject
? t('frame_title', { subject: email.subject })
: t('frame_title_from', { sender: frameSender?.name || frameSender?.email || t('unknown_sender') });
const emailIframeSrcDoc = useMemo(() => {
if (!effectiveEmailContent.isHtml) return '';
@@ -3195,7 +3204,7 @@ export function EmailViewer({
const iframeCsp = emailIframeCsp(effectiveEmailContent.externalBlocked);
return `<!DOCTYPE html>
<html class="${isDark ? DARK_PENDING_CLASS : ''}" style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<html${bodyLanguage ? ` lang="${bodyLanguage}"` : ''} class="${isDark ? DARK_PENDING_CLASS : ''}" style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${iframeCsp}">
<meta name="referrer" content="no-referrer">
<meta http-equiv="x-dns-prefetch-control" content="off">
@@ -3247,7 +3256,7 @@ export function EmailViewer({
${wordHtmlCSS}
${darkModeCSS}
</style></head><body dir="auto">${resolveColorSchemeMedia(effectiveEmailContent.html, isDark)}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body></html>`;
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode, messageSpacing]);
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode, messageSpacing, bodyLanguage]);
// Unblocking external content is handled by rebuilding the iframe srcDoc:
// toggling allowExternalContent (both "Load images" and "Trust sender" set
@@ -6156,7 +6165,7 @@ export function EmailViewer({
ref={iframeRef}
srcDoc={emailIframeSrcDoc}
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
title="Email content"
title={frameTitle}
className="w-full border-0 block"
scrolling="no"
style={{ minHeight: '100px', colorScheme: emailColorScheme(isDark, emailHasNativeDarkMode) }}
@@ -6167,6 +6176,7 @@ export function EmailViewer({
ref={setPlainBodyEl}
className="email-content-text text-foreground"
dir="auto"
lang={bodyLanguage ?? undefined}
data-plugin-source="email-body"
data-email-id={email?.id}
onContextMenu={(e) => selectionMenu.onElementContextMenu(e, email?.id)}
+12 -3
View File
@@ -15,6 +15,7 @@ import { ExternalSenderChip } from "./external-sender-chip";
import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { fitEmailBodyWidth } from "@/lib/email-fit-width";
import { mailLanguage } from "@/lib/mail-language";
import { DARK_PENDING_CLASS, applyEmailDarkMode, emailDarkModeCss } from "@/lib/email-dark-mode";
import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow";
@@ -756,13 +757,20 @@ export function ConversationEmailBody({
// the host page. CSP <meta> is defense-in-depth in case the sanitizer ever
// emits a <script> tag through a parser quirk.
const iframeRef = useRef<HTMLIFrameElement>(null);
// The body's language for screen readers (see mailLanguage), and a frame
// title that tells this message from the others in the conversation.
const bodyLanguage = useMemo(() => mailLanguage(email, getRenderableHtmlBody(email)), [email]);
const frameSender = email.from?.[0];
const frameTitle = email.subject
? t('email_viewer.frame_title', { subject: email.subject })
: t('email_viewer.frame_title_from', { sender: frameSender?.name || frameSender?.email || t('email_viewer.unknown_sender') });
const emailIframeSrcDoc = useMemo(() => {
if (!emailContent.isHtml || !emailContent.html) return '';
// Strict while external content is blocked: the network-level backstop
// for whatever the DOM walk above cannot see.
const csp = emailIframeCsp(!allowExternal);
// Colours are rewritten after load (applyEmailDarkMode in handleIframeLoad).
return `<!DOCTYPE html><html class="${isDark ? DARK_PENDING_CLASS : ''}"><head>
return `<!DOCTYPE html><html${bodyLanguage ? ` lang="${bodyLanguage}"` : ''} class="${isDark ? DARK_PENDING_CLASS : ''}"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${csp}">
@@ -794,7 +802,7 @@ export function ConversationEmailBody({
pre { white-space: pre-wrap; word-wrap: break-word; }
${isDark ? emailDarkModeCss() : ''}
</style></head><body dir="auto">${emailContent.html}</body></html>`;
}, [emailContent.isHtml, emailContent.html, allowExternal, isDark]);
}, [emailContent.isHtml, emailContent.html, allowExternal, isDark, bodyLanguage]);
useLayoutEffect(() => { setHtmlBodyEl(null); }, [emailIframeSrcDoc]);
// One object per body: React writes innerHTML again whenever this object
// changes, dropping whatever was mounted in the body.
@@ -911,7 +919,7 @@ export function ConversationEmailBody({
ref={iframeRef}
srcDoc={emailIframeSrcDoc}
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
title="Email content"
title={frameTitle}
className="w-full border-0 block"
scrolling="no"
style={{ minHeight: '60px' }}
@@ -923,6 +931,7 @@ export function ConversationEmailBody({
// Its own direction, as in the reader: English text in an Arabic
// UI read "1+" and ".change".
dir="auto"
lang={bodyLanguage ?? undefined}
className={cn(
"prose prose-sm max-w-none",
!emailAlwaysLightMode && "dark:prose-invert",
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { htmlDeclaredLanguage, mailLanguage } from '@/lib/mail-language';
describe('mailLanguage', () => {
it('prefers the body part Content-Language', () => {
expect(mailLanguage({ htmlBody: [{ language: ['fr-CA'] } as never], textBody: [], headers: { 'Content-Language': 'de' } }, '<html lang="en">')).toBe('fr-CA');
});
it('falls back to the message header, first tag of a list', () => {
expect(mailLanguage({ htmlBody: [], textBody: [], headers: { 'content-language': 'nl, en' } }, null)).toBe('nl');
});
it('falls back to the lang the HTML declares', () => {
expect(mailLanguage({ htmlBody: [], textBody: [] }, '<html xmlns="x" lang="es"><body>hola')).toBe('es');
// Word sets it on <body>.
expect(htmlDeclaredLanguage('<html><body lang=DE link=blue>')).toBe('DE');
});
it('is null when the mail says nothing or something that is not a tag', () => {
expect(mailLanguage({ htmlBody: [], textBody: [] }, '<p>hi</p>')).toBeNull();
expect(mailLanguage({ htmlBody: [], textBody: [], headers: { 'Content-Language': '"><script>' } }, null)).toBeNull();
});
});
+48
View File
@@ -0,0 +1,48 @@
import type { Email } from '@/lib/jmap/types';
/** A BCP 47 tag as mail carries it: "fr", "de-CH", "zh-Hant-TW". */
const LANGUAGE_TAG = /^[a-z]{2,3}(?:-[a-z0-9]{1,8})*$/i;
function cleanTag(value: string | null | undefined): string | null {
if (!value) return null;
// Content-Language may list several ("de, en"); the first one is the body's.
const first = value.split(',')[0].trim();
return LANGUAGE_TAG.test(first) ? first : null;
}
function headerValue(headers: Email['headers'], name: string): string | null {
if (!headers || Array.isArray(headers)) return null;
const key = Object.keys(headers).find((k) => k.toLowerCase() === name);
if (!key) return null;
const value = headers[key];
return Array.isArray(value) ? value[0] ?? null : value ?? null;
}
/** The `lang` an HTML body declares on its <html> or <body> element. */
export function htmlDeclaredLanguage(html: string | null | undefined): string | null {
if (!html) return null;
const head = html.slice(0, 20000);
for (const tag of ['html', 'body']) {
const match = new RegExp(`<${tag}\\b[^>]*?\\s(?:xml:)?lang\\s*=\\s*["']?([^"'\\s>]+)`, 'i').exec(head);
const lang = cleanTag(match?.[1]);
if (lang) return lang;
}
return null;
}
/**
* The language a message body is written in, for `lang` on the frame it
* renders in, so a screen reader reads a French mail with a French voice.
* Taken from the body part's Content-Language, the message header, or the
* HTML's own `lang`, in that order; null when the mail doesn't say.
*/
export function mailLanguage(
email: Pick<Email, 'htmlBody' | 'textBody' | 'headers'> | null | undefined,
html?: string | null,
): string | null {
if (!email) return htmlDeclaredLanguage(html);
const part = email.htmlBody?.[0] ?? email.textBody?.[0];
return cleanTag(part?.language?.[0])
?? cleanTag(headerValue(email.headers, 'content-language'))
?? htmlDeclaredLanguage(html);
}
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "في {folder}",
"failed": "تعذّر الحفظ في الملفات",
"load_failed": "تعذّر تحميل مجلداتك. حاول مرة أخرى."
}
},
"frame_title": "الرسالة: {subject}",
"frame_title_from": "رسالة من {sender}"
},
"external_mail": {
"sender_label": "خارجي",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "A {folder}",
"failed": "No s'ha pogut desar a Fitxers",
"load_failed": "No s'han pogut carregar les carpetes. Torna-ho a provar."
}
},
"frame_title": "Missatge: {subject}",
"frame_title_from": "Missatge de {sender}"
},
"external_mail": {
"sender_label": "Extern",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "Do složky {folder}",
"failed": "Nepodařilo se uložit do Souborů",
"load_failed": "Složky se nepodařilo načíst. Zkuste to znovu."
}
},
"frame_title": "Zpráva: {subject}",
"frame_title_from": "Zpráva od {sender}"
},
"external_mail": {
"sender_label": "Externí",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "I {folder}",
"failed": "Kunne ikke gemme i Filer",
"load_failed": "Mapperne kunne ikke indlæses. Prøv igen."
}
},
"frame_title": "Besked: {subject}",
"frame_title_from": "Besked fra {sender}"
},
"external_mail": {
"sender_label": "Ekstern",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "In {folder}",
"failed": "Konnte nicht in Dateien gespeichert werden",
"load_failed": "Ihre Ordner konnten nicht geladen werden. Versuchen Sie es erneut."
}
},
"frame_title": "Nachricht: {subject}",
"frame_title_from": "Nachricht von {sender}"
},
"external_mail": {
"sender_label": "Extern",
+3 -1
View File
@@ -767,7 +767,9 @@
"load_failed": "Couldn't load your folders. Try again."
},
"plugin_panel_show": "Show plugin panel",
"plugin_panel_hide": "Hide plugin panel"
"plugin_panel_hide": "Hide plugin panel",
"frame_title": "Message: {subject}",
"frame_title_from": "Message from {sender}"
},
"external_mail": {
"sender_label": "External",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "En {folder}",
"failed": "No se pudo guardar en Archivos",
"load_failed": "No se pudieron cargar tus carpetas. Vuelve a intentarlo."
}
},
"frame_title": "Mensaje: {subject}",
"frame_title_from": "Mensaje de {sender}"
},
"external_mail": {
"sender_label": "Externo",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "Kausta {folder}",
"failed": "Failidesse salvestamine ei õnnestunud",
"load_failed": "Kaustu ei õnnestunud laadida. Proovige uuesti."
}
},
"frame_title": "Kiri: {subject}",
"frame_title_from": "Kiri saatjalt {sender}"
},
"external_mail": {
"sender_label": "Väline",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "در {folder}",
"failed": "ذخیره در فایل‌ها ممکن نشد",
"load_failed": "پوشه‌ها بارگذاری نشدند. دوباره امتحان کنید."
}
},
"frame_title": "پیام: {subject}",
"frame_title_from": "پیام از {sender}"
},
"external_mail": {
"sender_label": "خارجی",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "Dans {folder}",
"failed": "Impossible d'enregistrer dans Fichiers",
"load_failed": "Impossible de charger vos dossiers. Réessayez."
}
},
"frame_title": "Message : {subject}",
"frame_title_from": "Message de {sender}"
},
"external_mail": {
"sender_label": "Externe",
+3 -1
View File
@@ -732,7 +732,9 @@
"saved_to": "ב{folder}",
"failed": "לא ניתן היה לשמור בקבצים",
"load_failed": "לא ניתן היה לטעון את התיקיות. נסו שוב."
}
},
"frame_title": "הודעה: {subject}",
"frame_title_from": "הודעה מאת {sender}"
},
"external_mail": {
"sender_label": "חיצוני",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "Ide: {folder}",
"failed": "Nem sikerült menteni a Fájlokba",
"load_failed": "Nem sikerült betölteni a mappákat. Próbálja újra."
}
},
"frame_title": "Üzenet: {subject}",
"frame_title_from": "Üzenet tőle: {sender}"
},
"external_mail": {
"sender_label": "Külső",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "In {folder}",
"failed": "Impossibile salvare in File",
"load_failed": "Impossibile caricare le cartelle. Riprova."
}
},
"frame_title": "Messaggio: {subject}",
"frame_title_from": "Messaggio da {sender}"
},
"external_mail": {
"sender_label": "Esterno",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "{folder} に保存",
"failed": "ファイルに保存できませんでした",
"load_failed": "フォルダを読み込めませんでした。もう一度お試しください。"
}
},
"frame_title": "メッセージ: {subject}",
"frame_title_from": "{sender} からのメッセージ"
},
"external_mail": {
"sender_label": "外部",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "{folder}에 저장",
"failed": "파일에 저장하지 못했어요",
"load_failed": "폴더를 불러오지 못했어요. 다시 시도하세요."
}
},
"frame_title": "메시지: {subject}",
"frame_title_from": "{sender}님의 메시지"
},
"external_mail": {
"sender_label": "외부",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "Mapē {folder}",
"failed": "Neizdevās saglabāt failos",
"load_failed": "Neizdevās ielādēt mapes. Mēģiniet vēlreiz."
}
},
"frame_title": "Ziņojums: {subject}",
"frame_title_from": "Ziņojums no {sender}"
},
"external_mail": {
"sender_label": "Ārējs",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "{folder}-д",
"failed": "Файлд хадгалж чадсангүй",
"load_failed": "Хавтсуудыг ачаалж чадсангүй. Дахин оролдоно уу."
}
},
"frame_title": "Захидал: {subject}",
"frame_title_from": "{sender}-с ирсэн захидал"
},
"external_mail": {
"sender_label": "Гадаад",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "I {folder}",
"failed": "Kunne ikke lagre i Filer",
"load_failed": "Kunne ikke laste inn mappene. Prøv igjen."
}
},
"frame_title": "Melding: {subject}",
"frame_title_from": "Melding fra {sender}"
},
"external_mail": {
"sender_label": "Ekstern",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "In {folder}",
"failed": "Kon niet opslaan in Bestanden",
"load_failed": "Kon je mappen niet laden. Probeer het opnieuw."
}
},
"frame_title": "Bericht: {subject}",
"frame_title_from": "Bericht van {sender}"
},
"external_mail": {
"sender_label": "Extern",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "W folderze {folder}",
"failed": "Nie udało się zapisać w Plikach",
"load_failed": "Nie udało się wczytać folderów. Spróbuj ponownie."
}
},
"frame_title": "Wiadomość: {subject}",
"frame_title_from": "Wiadomość od {sender}"
},
"external_mail": {
"sender_label": "Zewnętrzny",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "Em {folder}",
"failed": "Não foi possível guardar em Ficheiros",
"load_failed": "Não foi possível carregar as pastas. Tente novamente."
}
},
"frame_title": "Mensagem: {subject}",
"frame_title_from": "Mensagem de {sender}"
},
"external_mail": {
"sender_label": "Externo",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "În {folder}",
"failed": "Nu s-a putut salva în Fișiere",
"load_failed": "Dosarele nu au putut fi încărcate. Încearcă din nou."
}
},
"frame_title": "Mesaj: {subject}",
"frame_title_from": "Mesaj de la {sender}"
},
"external_mail": {
"sender_label": "Extern",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "В папку {folder}",
"failed": "Не удалось сохранить в «Файлы»",
"load_failed": "Не удалось загрузить папки. Повторите попытку."
}
},
"frame_title": "Письмо: {subject}",
"frame_title_from": "Письмо от {sender}"
},
"external_mail": {
"sender_label": "Внешнее",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "Do priečinka {folder}",
"failed": "Nepodarilo sa uložiť do Súborov",
"load_failed": "Priečinky sa nepodarilo načítať. Skúste to znova."
}
},
"frame_title": "Správa: {subject}",
"frame_title_from": "Správa od {sender}"
},
"external_mail": {
"sender_label": "Externé",
+3 -1
View File
@@ -1697,7 +1697,9 @@
"load_failed": "ไม่สามารถโหลดโฟลเดอร์ได้ ลองอีกครั้ง"
},
"plugin_panel_show": "แสดงแถบปลั๊กอิน",
"plugin_panel_hide": "ซ่อนแถบปลั๊กอิน"
"plugin_panel_hide": "ซ่อนแถบปลั๊กอิน",
"frame_title": "ข้อความ: {subject}",
"frame_title_from": "ข้อความจาก {sender}"
},
"external_mail": {
"sender_label": "ภายนอก",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "{folder} içine",
"failed": "Dosyalar'a kaydedilemedi",
"load_failed": "Klasörler yüklenemedi. Yeniden deneyin."
}
},
"frame_title": "İleti: {subject}",
"frame_title_from": "{sender} adlı kişiden ileti"
},
"external_mail": {
"sender_label": "Harici",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "У теку {folder}",
"failed": "Не вдалося зберегти у «Файли»",
"load_failed": "Не вдалося завантажити теки. Спробуйте ще раз."
}
},
"frame_title": "Лист: {subject}",
"frame_title_from": "Лист від {sender}"
},
"external_mail": {
"sender_label": "Зовнішнє",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "在 {folder} 中",
"failed": "無法儲存到檔案",
"load_failed": "無法載入資料夾。請再試一次。"
}
},
"frame_title": "郵件:{subject}",
"frame_title_from": "來自 {sender} 的郵件"
},
"external_mail": {
"sender_label": "外部",
+3 -1
View File
@@ -767,7 +767,9 @@
"saved_to": "在 {folder} 中",
"failed": "无法保存到文件",
"load_failed": "无法加载文件夹。请重试。"
}
},
"frame_title": "邮件:{subject}",
"frame_title_from": "来自 {sender} 的邮件"
},
"external_mail": {
"sender_label": "外部",