mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Merge branch 'feat/e-invoice-host'
This commit is contained in:
@@ -168,6 +168,17 @@ describe('plugin badges in message-list rows', () => {
|
||||
expect(screen.queryByTestId('plugin-row-badges')).toBeNull();
|
||||
transform.mockRestore();
|
||||
});
|
||||
|
||||
it('asks rows drawn before the plugin started once its handler arrives, and drops the badge when it goes', async () => {
|
||||
render(row([makeEmail()]));
|
||||
await act(async () => {});
|
||||
expect(screen.queryByTestId('plugin-row-badges')).toBeNull();
|
||||
let off: { dispose: () => void } = { dispose: () => {} };
|
||||
act(() => { off = renderHooks.onEmailListItemRender.register('e-invoice', () => [{ key: 'e', label: 'E-invoice' }]); });
|
||||
expect(await screen.findByText('E-invoice')).toBeInTheDocument();
|
||||
act(() => { off.dispose(); });
|
||||
await waitFor(() => expect(screen.queryByText('E-invoice')).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeBadges', () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { effectiveSettings } from '@/lib/plugin-settings';
|
||||
import { PLUGIN_REFUSED_ERROR } from '@/lib/outgoing-hosts';
|
||||
import { lookup } from '@/lib/plugin-sandbox/plugin-i18n';
|
||||
import { forgetCredentials, listConnectedAccounts, type ConnectedAccount } from '@/lib/plugin-sandbox/connect-dialog';
|
||||
import { forwardApprovals, revokeForwardApproval } from '@/lib/plugin-sandbox/forward-approvals';
|
||||
|
||||
const STATUS_COLORS: Record<PluginStatus, string> = {
|
||||
installed: 'bg-muted text-muted-foreground',
|
||||
@@ -326,6 +327,8 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, controlsDisabled, onTo
|
||||
<ConnectedAccounts pluginId={plugin.id} />
|
||||
)}
|
||||
|
||||
{plugin.permissions.includes('email:forward') && <ForwardApprovals pluginId={plugin.id} />}
|
||||
|
||||
{!isManaged && (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={onRemove} className="text-destructive hover:text-destructive">
|
||||
@@ -383,6 +386,40 @@ function ConnectedAccounts({ pluginId }: { pluginId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Forward addresses ───────────────────────────────────────
|
||||
|
||||
/** Addresses the user let this plugin forward mail to (api.email.forward). */
|
||||
function ForwardApprovals({ pluginId }: { pluginId: string }) {
|
||||
const t = useTranslations('plugins.settings');
|
||||
const [addresses, setAddresses] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setAddresses(forwardApprovals(pluginId));
|
||||
}, [pluginId]);
|
||||
|
||||
if (addresses.length === 0) return null;
|
||||
|
||||
const remove = (address: string) => {
|
||||
revokeForwardApproval(pluginId, address);
|
||||
setAddresses(forwardApprovals(pluginId));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-foreground">{t('forward_heading')}</span>
|
||||
<p className="text-[0.625rem] text-muted-foreground">{t('forward_hint')}</p>
|
||||
{addresses.map((address) => (
|
||||
<div key={address} className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="min-w-0 truncate text-muted-foreground">{address}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => remove(address)} aria-label={t('forward_remove_label', { address })}>
|
||||
{t('forward_remove')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Auto-generated Setting Field ────────────────────────────
|
||||
|
||||
interface PluginSettingFieldProps {
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { InstalledPlugin } from '../plugin-types';
|
||||
import type { IJMAPClient } from '../jmap/client-interface';
|
||||
import type { Email } from '../jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { emailHooks } from '../plugin-hooks';
|
||||
import { head, resolveHead, resetDialogLimits } from '../plugin-sandbox/host-dialog';
|
||||
import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input';
|
||||
import { forwardApprovals, revokeForwardApproval } from '../plugin-sandbox/forward-approvals';
|
||||
import { clearPluginStorageForAccount } from '../plugin-sandbox/storage-scope';
|
||||
import { HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text';
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
|
||||
}));
|
||||
|
||||
import { dispatchApiCall } from '../plugin-sandbox/host-api';
|
||||
|
||||
function plugin(perms: string[], id = 'e-invoice'): InstalledPlugin {
|
||||
return {
|
||||
id, name: 'E-Invoice', version: '1.0.0', author: 'test', description: '',
|
||||
type: 'ui-extension', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
|
||||
enabled: true, status: 'running', settings: {},
|
||||
};
|
||||
}
|
||||
|
||||
const FORWARDER = plugin(['email:forward']);
|
||||
const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args);
|
||||
|
||||
const invoice: Email = {
|
||||
id: 'E1',
|
||||
threadId: 'T1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: {},
|
||||
size: 9000,
|
||||
receivedAt: '2026-10-20T08:00:00Z',
|
||||
from: [{ name: 'Supplier GmbH', email: 'billing@supplier.example' }],
|
||||
to: [{ name: 'Ann', email: 'ann@example.com' }],
|
||||
subject: 'Rechnung 2026-117',
|
||||
textBody: [{ partId: '1', blobId: 'tb', size: 20, type: 'text/plain' }],
|
||||
bodyValues: { 1: { value: 'Please find the invoice attached.' } },
|
||||
attachments: [
|
||||
{ partId: '2', blobId: 'pdf-1', name: 'invoice.pdf', type: 'application/pdf', size: 5000, disposition: 'attachment' },
|
||||
{ partId: '3', blobId: 'xml-1', name: 'xrechnung.xml', type: 'application/xml', size: 3000, disposition: 'attachment' },
|
||||
{ partId: '4', blobId: 'logo', name: 'logo.png', type: 'image/png', size: 900, disposition: 'inline', cid: 'logo@x' },
|
||||
],
|
||||
} as unknown as Email;
|
||||
|
||||
/** Answer the host's confirm once it shows. */
|
||||
async function answerDialog(allow: boolean): Promise<string> {
|
||||
await vi.waitFor(() => expect(head()).not.toBeNull());
|
||||
const shown = head()!;
|
||||
resolveHead(allow);
|
||||
return `${shown.title}\n${shown.message}`;
|
||||
}
|
||||
|
||||
describe('api.email.forward', () => {
|
||||
let client: Record<string, ReturnType<typeof vi.fn>>;
|
||||
let sendEmail: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
resetPluginRateLimits();
|
||||
resetDialogLimits();
|
||||
localStorage.clear();
|
||||
client = {
|
||||
getAccountId: vi.fn(() => 'own'),
|
||||
getEmail: vi.fn(async (id: string, accountId?: string) => (id === 'E1' ? { ...invoice, accountId } : null)),
|
||||
getMailboxes: vi.fn(async () => []),
|
||||
resolveSendAccountId: vi.fn(async () => 'own'),
|
||||
copyBlobs: vi.fn(async (_from: string, ids: string[]) => Object.fromEntries(ids.map((id) => [id, `copy-${id}`]))),
|
||||
hasDelayedSend: vi.fn(() => true),
|
||||
batchUpdateKeywords: vi.fn(async () => undefined),
|
||||
};
|
||||
sendEmail = vi.fn(async () => ({ emailId: 'S1', emailSubmissionId: 'sub-1', scheduled: false }));
|
||||
useAuthStore.setState({ client: client as unknown as IJMAPClient });
|
||||
useAccountStore.setState({ activeAccountId: 'acct-1' });
|
||||
useEmailStore.setState({ sendEmail, emails: [], selectedEmail: null, mailboxes: [] } as never);
|
||||
useIdentityStore.setState({
|
||||
identities: [{ id: 'id-a', name: 'Ann', email: 'ann@example.com' }] as never,
|
||||
preferredPrimaryId: 'id-a',
|
||||
});
|
||||
useSettingsStore.setState({ sendDelaySeconds: 0 });
|
||||
});
|
||||
afterEach(() => {
|
||||
while (head()) resolveHead(false);
|
||||
useAuthStore.setState({ client: null });
|
||||
useIdentityStore.setState({ identities: [], preferredPrimaryId: null });
|
||||
});
|
||||
|
||||
it('asks before the first forward to an address, then sends with the attachments and marks the original', async () => {
|
||||
const pending = call(FORWARDER, 'email.forward', 'E1', { to: ['Accounting <Buchhaltung@Firma.example>'], text: 'For booking.' });
|
||||
const shown = await answerDialog(true);
|
||||
expect(shown).toContain('E-Invoice');
|
||||
expect(shown).toContain('buchhaltung@firma.example');
|
||||
expect(shown).toContain('Rechnung 2026-117');
|
||||
expect(await pending).toEqual({ sent: true, emailId: 'S1', to: ['buchhaltung@firma.example'], scheduledFor: null });
|
||||
|
||||
expect(sendEmail).toHaveBeenCalledTimes(1);
|
||||
const [, to, subject, body, , , identityId, fromEmail, , , html, attachments, , , delayedUntil] = sendEmail.mock.calls[0];
|
||||
expect({ to, subject, identityId, fromEmail, html, delayedUntil }).toEqual({
|
||||
to: ['Accounting <Buchhaltung@Firma.example>'], subject: 'Fwd: Rechnung 2026-117',
|
||||
identityId: 'id-a', fromEmail: 'ann@example.com', html: undefined, delayedUntil: undefined,
|
||||
});
|
||||
expect(body).toMatch(/^For booking\.\n\n---------- Forwarded message ----------\nFrom: Supplier GmbH <billing@supplier\.example>\n/);
|
||||
expect(body).toContain('Subject: Rechnung 2026-117');
|
||||
expect(body).toContain('Please find the invoice attached.');
|
||||
// The inline logo of the body stays behind.
|
||||
expect(attachments).toEqual([
|
||||
{ blobId: 'pdf-1', name: 'invoice.pdf', type: 'application/pdf', size: 5000, disposition: 'attachment' },
|
||||
{ blobId: 'xml-1', name: 'xrechnung.xml', type: 'application/xml', size: 3000, disposition: 'attachment' },
|
||||
]);
|
||||
expect(client.batchUpdateKeywords).toHaveBeenCalledWith(['E1'], { 'keywords/$forwarded': true }, undefined);
|
||||
expect(forwardApprovals('e-invoice')).toEqual(['buchhaltung@firma.example']);
|
||||
|
||||
// The next forward there goes out without a question.
|
||||
expect(await call(FORWARDER, 'email.forward', 'E1', { to: ['buchhaltung@firma.example'] })).toMatchObject({ sent: true });
|
||||
expect(head()).toBeNull();
|
||||
expect(sendEmail).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('sends nothing and remembers nothing when the user says no', async () => {
|
||||
const pending = call(FORWARDER, 'email.forward', 'E1', { to: ['eve@evil.example'] });
|
||||
await answerDialog(false);
|
||||
expect(await pending).toEqual({ sent: false, reason: 'declined' });
|
||||
expect(sendEmail).not.toHaveBeenCalled();
|
||||
expect(forwardApprovals('e-invoice')).toEqual([]);
|
||||
});
|
||||
|
||||
it('asks again for an address that is new, even next to an allowed one', async () => {
|
||||
revokeForwardApproval('e-invoice', 'nobody@example.com');
|
||||
const first = call(FORWARDER, 'email.forward', 'E1', { to: ['books@example.com'] });
|
||||
await answerDialog(true);
|
||||
await first;
|
||||
const second = call(FORWARDER, 'email.forward', 'E1', { to: ['books@example.com', 'eve@evil.example'] });
|
||||
const shown = await answerDialog(false);
|
||||
expect(shown).toContain('eve@evil.example');
|
||||
expect(shown).not.toContain('books@example.com');
|
||||
expect(await second).toEqual({ sent: false, reason: 'declined' });
|
||||
expect(sendEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends only the attachments named, and only attachments of that message', async () => {
|
||||
const pending = call(FORWARDER, 'email.forward', 'E1', { to: ['books@example.com'], attachments: ['xml-1'] });
|
||||
await answerDialog(true);
|
||||
await pending;
|
||||
expect(sendEmail.mock.calls[0][11]).toEqual([expect.objectContaining({ blobId: 'xml-1' })]);
|
||||
await expect(call(FORWARDER, 'email.forward', 'E1', { to: ['books@example.com'], attachments: ['someone-elses-blob'] }))
|
||||
.rejects.toThrow(/not an attachment of this message/);
|
||||
});
|
||||
|
||||
it('copies the attachments when the message is in a team mailbox', async () => {
|
||||
useEmailStore.setState({ mailboxes: [{ id: 'team-inbox', name: 'Inbox', role: 'inbox', isShared: true, accountId: 'team' }] } as never);
|
||||
const pending = call(FORWARDER, 'email.forward', 'E1', { to: ['books@example.com'], accountId: 'team' });
|
||||
await answerDialog(true);
|
||||
await pending;
|
||||
expect(client.getEmail).toHaveBeenCalledWith('E1', 'team');
|
||||
expect(client.copyBlobs).toHaveBeenCalledWith('team', ['pdf-1', 'xml-1'], 'own');
|
||||
expect(sendEmail.mock.calls[0][11].map((a: { blobId: string }) => a.blobId)).toEqual(['copy-pdf-1', 'copy-xml-1']);
|
||||
expect(client.batchUpdateKeywords).toHaveBeenCalledWith(['E1'], { 'keywords/$forwarded': true }, 'team');
|
||||
});
|
||||
|
||||
it('holds the forward for the undo delay like any other send', async () => {
|
||||
useSettingsStore.setState({ sendDelaySeconds: 10 });
|
||||
sendEmail.mockResolvedValueOnce({ emailId: 'S1', emailSubmissionId: 'sub-1', scheduled: true, sendAt: '2026-10-29T10:00:10Z' });
|
||||
const pending = call(FORWARDER, 'email.forward', 'E1', { to: ['books@example.com'] });
|
||||
await answerDialog(true);
|
||||
expect(await pending).toMatchObject({ sent: true, scheduledFor: '2026-10-29T10:00:10Z' });
|
||||
expect(sendEmail.mock.calls[0][14]).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('lets another plugin stop it in onBeforeEmailSend', async () => {
|
||||
const seen: unknown[] = [];
|
||||
const off = emailHooks.onBeforeEmailSend.register('guardrail', (outgoing) => { seen.push(outgoing); return false; });
|
||||
try {
|
||||
const pending = call(FORWARDER, 'email.forward', 'E1', { to: ['books@example.com'] });
|
||||
await answerDialog(true);
|
||||
expect(await pending).toEqual({ sent: false, reason: 'stopped' });
|
||||
expect(seen).toEqual([expect.objectContaining({ subject: 'Fwd: Rechnung 2026-117', to: ['books@example.com'] })]);
|
||||
expect(sendEmail).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
off.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('needs email:forward and sound input', async () => {
|
||||
await expect(call(plugin(['email:read', 'email:send']), 'email.forward', 'E1', { to: ['a@example.com'] }))
|
||||
.rejects.toThrow(/lacks permission "email:forward"/);
|
||||
await expect(call(FORWARDER, 'email.forward', 'E1', { to: [] })).rejects.toThrow(/at least one address/);
|
||||
await expect(call(FORWARDER, 'email.forward', 'E1', { to: Array.from({ length: 11 }, (_, i) => `u${i}@example.com`) }))
|
||||
.rejects.toThrow(/at most 10 addresses/);
|
||||
await expect(call(FORWARDER, 'email.forward', 'E1', { to: ['a@example.com'], bcc: ['eve@evil.example'] }))
|
||||
.rejects.toThrow(/unknown property "bcc"/);
|
||||
await expect(call(FORWARDER, 'email.forward', 'missing', { to: ['a@example.com'] })).rejects.toThrow(/no message/);
|
||||
expect(sendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is high risk in the consent dialog', () => {
|
||||
expect(HIGH_RISK_PERMISSIONS.has('email:forward')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forward approvals', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useAccountStore.setState({ activeAccountId: 'acct-1' });
|
||||
});
|
||||
|
||||
it('are out of reach of the plugin’s own storage', async () => {
|
||||
const p = plugin(['email:forward']);
|
||||
localStorage.setItem('plugin:e-invoice:!forward-to:@acct-1:', JSON.stringify(['books@example.com']));
|
||||
expect(await call(p, 'storage.keys')).toEqual([]);
|
||||
await call(p, 'storage.set', '!forward-to', ['eve@evil.example']);
|
||||
await call(p, 'storage.set', ':!forward-to:@acct-1:', ['eve@evil.example']);
|
||||
expect(forwardApprovals('e-invoice')).toEqual(['books@example.com']);
|
||||
});
|
||||
|
||||
it('go when the account signs out or the user removes them', () => {
|
||||
localStorage.setItem('plugin:e-invoice:!forward-to:@acct-1:', JSON.stringify(['a@example.com', 'b@example.com']));
|
||||
localStorage.setItem('plugin:e-invoice:!forward-to:@acct-2:', JSON.stringify(['c@example.com']));
|
||||
revokeForwardApproval('e-invoice', 'A@example.com');
|
||||
expect(forwardApprovals('e-invoice')).toEqual(['b@example.com']);
|
||||
clearPluginStorageForAccount('acct-1');
|
||||
expect(forwardApprovals('e-invoice')).toEqual([]);
|
||||
expect(forwardApprovals('e-invoice', 'acct-2')).toEqual(['c@example.com']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { InstalledPlugin } from '../plugin-types';
|
||||
import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input';
|
||||
import { printDocument } from '../plugin-sandbox/host-api-print';
|
||||
|
||||
vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
|
||||
}));
|
||||
|
||||
import { dispatchApiCall } from '../plugin-sandbox/host-api';
|
||||
|
||||
const PLUGIN: InstalledPlugin = {
|
||||
id: 'e-invoice', name: 'E-Invoice', version: '1.0.0', author: 'test', description: '',
|
||||
type: 'ui-extension', permissions: [], grantedPermissions: [], entrypoint: 'index.js',
|
||||
enabled: true, status: 'running', settings: {},
|
||||
};
|
||||
const call = (method: string, ...args: unknown[]) => dispatchApiCall(PLUGIN, method, args);
|
||||
|
||||
describe('api.ui.print', () => {
|
||||
let printed: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
resetPluginRateLimits();
|
||||
printed = [];
|
||||
document.querySelectorAll('iframe').forEach((f) => f.remove());
|
||||
// jsdom neither loads srcdoc nor prints: load the frame when it is
|
||||
// attached and record what its print() would have printed.
|
||||
vi.spyOn(document.body, 'appendChild').mockImplementation(function (this: HTMLElement, node: Node) {
|
||||
HTMLElement.prototype.appendChild.call(this, node);
|
||||
if (node instanceof HTMLIFrameElement) {
|
||||
const srcdoc = node.srcdoc;
|
||||
node.contentWindow!.print = () => { printed.push(srcdoc); };
|
||||
queueMicrotask(() => node.dispatchEvent(new Event('load')));
|
||||
}
|
||||
return node;
|
||||
} as never);
|
||||
});
|
||||
|
||||
it('prints the page from a frame that runs no scripts and loads nothing', async () => {
|
||||
expect(await call('ui.print', {
|
||||
title: 'Rechnung 2026-117',
|
||||
html: '<style>td { padding: 4px }</style><table><tr><td>Total</td><td>119,00 €</td></tr></table>'
|
||||
+ '<script>parent.steal()</script><img src="https://tracker.example/p.gif"><form action="https://x.example"><input name="a"></form>',
|
||||
})).toBe(true);
|
||||
expect(printed).toHaveLength(1);
|
||||
const page = printed[0];
|
||||
expect(page).toContain('<title>Rechnung 2026-117</title>');
|
||||
expect(page).toContain('<td>119,00 €</td>');
|
||||
expect(page).toContain('td { padding: 4px }');
|
||||
expect(page).not.toContain('<script');
|
||||
expect(page).not.toContain('<form');
|
||||
expect(page).toMatch(/default-src 'none'; img-src data: blob:;/);
|
||||
const frame = document.querySelector('iframe')!;
|
||||
expect(frame.getAttribute('sandbox')).toBe('allow-same-origin allow-modals');
|
||||
});
|
||||
|
||||
it('escapes the title and refuses bad input', async () => {
|
||||
expect(printDocument('</title><script>x()</script>', '', 'ltr')).toContain('<title></title><script>');
|
||||
await expect(call('ui.print', { html: 42 })).rejects.toThrow(/html must be a string/);
|
||||
await expect(call('ui.print', { title: 'a\nb', html: '' })).rejects.toThrow(/title must be one line/);
|
||||
await expect(call('ui.print', { html: '', url: 'https://x.example' })).rejects.toThrow(/unknown property "url"/);
|
||||
expect(printed).toEqual([]);
|
||||
});
|
||||
|
||||
it('prints at most three times a minute', async () => {
|
||||
for (let i = 0; i < 3; i++) await call('ui.print', { html: '<p>x</p>' });
|
||||
await expect(call('ui.print', { html: '<p>x</p>' })).rejects.toThrow(/at most 3 times a minute/);
|
||||
expect(printed).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,7 @@ describe('plugin permissions', () => {
|
||||
'email.getRaw': 'email:blob-read',
|
||||
'compose.open': 'compose:open',
|
||||
'drafts.create': 'email:write',
|
||||
'email.forward': 'email:forward',
|
||||
'notifications.show': 'ui:notifications',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,6 +127,35 @@ describe('plugin UI kit components', () => {
|
||||
expect(container.querySelector('dd')).toHaveTextContent('2 MB');
|
||||
});
|
||||
|
||||
it('draws a table with amounts aligned to the end and totals under the last column', () => {
|
||||
render(<kit.Table
|
||||
label="Items"
|
||||
columns={[
|
||||
{ key: 'name', label: 'Item' },
|
||||
{ key: 'qty', label: 'Qty', align: 'end', hideNarrow: true },
|
||||
{ key: 'net', label: 'Net', align: 'end' },
|
||||
]}
|
||||
rows={[{ key: 'a', name: 'Consulting', qty: '8 h', net: '800.00' }, { name: 'Travel', qty: '1', net: '120.50' }]}
|
||||
summary={[{ label: 'VAT 19 %', value: '174.80' }, { label: 'Total', value: '1,095.30', strong: true }]}
|
||||
/>);
|
||||
const table = screen.getByRole('table', { name: 'Items' });
|
||||
expect(screen.getAllByRole('columnheader').map((h) => h.textContent)).toEqual(['Item', 'Qty', 'Net']);
|
||||
const travel = screen.getByText('Travel').closest('tr')!;
|
||||
expect([...travel.children].map((c) => c.textContent)).toEqual(['Travel', '1', '120.50']);
|
||||
expect(screen.getByText('120.50').className).toContain('text-end');
|
||||
expect(screen.getByText('1').className).toContain('hidden sm:table-cell');
|
||||
const total = screen.getByRole('rowheader', { name: 'Total' });
|
||||
expect(total.getAttribute('colspan')).toBe('2');
|
||||
expect(total.closest('tr')!.lastElementChild!).toHaveTextContent('1,095.30');
|
||||
expect(total.className).toContain('font-semibold');
|
||||
expect(table.closest('.bk')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('says so when a table has no rows', () => {
|
||||
render(<kit.Table columns={[{ key: 'a', label: 'A' }, { key: 'b', label: 'B' }]} rows={[]} empty="No items" />);
|
||||
expect(screen.getByText('No items').getAttribute('colspan')).toBe('2');
|
||||
});
|
||||
|
||||
it('makes sidebar rows as tall as folder rows on touch screens', () => {
|
||||
// The page sets --touch-row-min-h to 44px below lg, where folder rows
|
||||
// are max-lg:min-h-[44px]; the frame can't see the page's width itself.
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
|
||||
import { debug } from '@/lib/debug';
|
||||
|
||||
export type SendSurface = 'composer' | 'quick-reply' | 'calendar-invitation';
|
||||
// 'plugin-forward': a plugin forwarding a message (api.email.forward).
|
||||
export type SendSurface = 'composer' | 'quick-reply' | 'calendar-invitation' | 'plugin-forward';
|
||||
|
||||
export interface OutgoingContext {
|
||||
surface: SendSurface;
|
||||
|
||||
+16
-7
@@ -1,6 +1,7 @@
|
||||
// Plugin Hook Bus - event bus system for plugin lifecycle hooks
|
||||
|
||||
import type { CalendarEventFormView, Disposable } from './plugin-types';
|
||||
import { rowBadgeHandlersChanged } from './plugin-sandbox/row-badges';
|
||||
|
||||
// ─── Error Tracker (Circuit Breaker) ─────────────────────────
|
||||
|
||||
@@ -158,31 +159,38 @@ export class HookBus<T extends (...args: any[]) => any> {
|
||||
* @param opts.failClosed For intercepts guarding an action that can't be
|
||||
* taken back (sending mail): a handler that throws or runs out of time
|
||||
* cancels the action instead of letting it through.
|
||||
* @param opts.onChange Called when handlers come or go, for UI that asked
|
||||
* the bus before a plugin started (row badges).
|
||||
*/
|
||||
constructor(
|
||||
private readonly timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
||||
private readonly opts: { failClosed?: boolean } = {},
|
||||
private readonly opts: { failClosed?: boolean; onChange?: () => void } = {},
|
||||
) {}
|
||||
|
||||
private setHandlers(next: HookEntry<T>[]): void {
|
||||
const changed = next.length !== this.handlers.length;
|
||||
this.handlers = next;
|
||||
if (changed) this.opts.onChange?.();
|
||||
}
|
||||
|
||||
register(pluginId: string, handler: T, order: number = 100): Disposable {
|
||||
const entry: HookEntry<T> = { pluginId, handler, order };
|
||||
this.handlers.push(entry);
|
||||
this.handlers.sort((a, b) => a.order - b.order);
|
||||
this.setHandlers([...this.handlers, entry].sort((a, b) => a.order - b.order));
|
||||
return {
|
||||
dispose: () => {
|
||||
this.handlers = this.handlers.filter(h => h !== entry);
|
||||
this.setHandlers(this.handlers.filter(h => h !== entry));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Remove all handlers for a given plugin */
|
||||
removePlugin(pluginId: string): void {
|
||||
this.handlers = this.handlers.filter(h => h.pluginId !== pluginId);
|
||||
this.setHandlers(this.handlers.filter(h => h.pluginId !== pluginId));
|
||||
}
|
||||
|
||||
/** Remove all handlers */
|
||||
clear(): void {
|
||||
this.handlers = [];
|
||||
this.setHandlers([]);
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
@@ -837,7 +845,8 @@ export const renderHooks = {
|
||||
// Second argument: { emailId: string; email: EmailReadView }
|
||||
// Handlers return a new (or extended) badges array.
|
||||
// Rendered by the email list row component next to the subject line.
|
||||
onEmailListItemRender: new HookBus(),
|
||||
// Rows drawn before a plugin started ask again once its handler is there.
|
||||
onEmailListItemRender: new HookBus(undefined, { onChange: rowBadgeHandlersChanged }),
|
||||
// Transform hook - runs when an email is opened, BEFORE the viewer computes
|
||||
// the body it will render. Initial value: RenderableBody { html, text,
|
||||
// attachments, handledBy? }. Second argument: MessageContext { id,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { isServerSetKeyword } from '@/lib/jmap/server-keywords';
|
||||
// Resolve a message's plain-text body from its JMAP body parts. Plugins that
|
||||
// translate or scan content need the real body, not just the short `preview`
|
||||
// snippet. Prefers text/plain parts; falls back to stripped HTML, then preview.
|
||||
function plainTextFromEmail(email: Email): string {
|
||||
export function plainTextFromEmail(email: Email): string {
|
||||
const values = email.bodyValues || {};
|
||||
const collect = (parts?: { partId: string }[]) =>
|
||||
(parts || [])
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// The addresses the user let a plugin forward mail to (api.email.forward),
|
||||
// per plugin and signed-in account, in a key the plugin can't write
|
||||
// (hostOnlyPluginKey). Settings > Plugins lists them and takes them back.
|
||||
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { hostOnlyPluginKey } from './storage-scope';
|
||||
|
||||
const APPROVALS = 'forward-to';
|
||||
|
||||
export function approvalAccount(): string {
|
||||
return useAccountStore.getState().activeAccountId ?? 'signed-out';
|
||||
}
|
||||
|
||||
/** The addresses the user let `pluginId` forward to, in this account, lower case. */
|
||||
export function forwardApprovals(pluginId: string, accountId = approvalAccount()): string[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(hostOnlyPluginKey(pluginId, accountId, APPROVALS));
|
||||
const list: unknown = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(list) ? list.filter((a): a is string => typeof a === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveForwardApprovals(pluginId: string, accountId: string, list: string[]): void {
|
||||
const key = hostOnlyPluginKey(pluginId, accountId, APPROVALS);
|
||||
try {
|
||||
if (list.length) window.localStorage.setItem(key, JSON.stringify([...new Set(list)].sort()));
|
||||
else window.localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Storage unavailable: the user is asked again next time.
|
||||
}
|
||||
}
|
||||
|
||||
/** Take back the permission to forward to `address`. */
|
||||
export function revokeForwardApproval(pluginId: string, address: string, accountId = approvalAccount()): void {
|
||||
const lower = address.toLowerCase();
|
||||
saveForwardApprovals(pluginId, accountId, forwardApprovals(pluginId, accountId).filter((a) => a !== lower));
|
||||
}
|
||||
@@ -25,7 +25,7 @@ type Messages = Record<string, unknown>;
|
||||
const MAX_QUERY = 200;
|
||||
const MAX_RESULTS = 20;
|
||||
|
||||
async function messagesFor(locale: string): Promise<Messages> {
|
||||
export async function messagesFor(locale: string): Promise<Messages> {
|
||||
const loader = LOCALE_LOADERS[locale];
|
||||
if (locale === 'en' || !loader) return enMessages as Messages;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// Host side of api.email.forward: a plugin forwards a message of the account,
|
||||
// with its attachments, without opening the composer. The e-invoice plugin
|
||||
// uses it to send an invoice to the accounting address in one click.
|
||||
//
|
||||
// Sending in the user's name is the risky part, so:
|
||||
// - the first forward to an address asks the user in a dialog the host
|
||||
// draws (naming the plugin, the message and the address); "Allow" is
|
||||
// remembered for that plugin, address and account, and Settings > Plugins
|
||||
// lists and removes what was allowed;
|
||||
// - the forward goes through the same pre-send checks and finalisers as a
|
||||
// quick reply (external recipients, the organisation's disclaimer) and
|
||||
// other plugins' onBeforeEmailSend, and is held for the user's undo delay;
|
||||
// - the copy lands in Sent and the original is marked $forwarded, as after
|
||||
// a forward from the composer.
|
||||
|
||||
import type { EmailForwardResult, InstalledPlugin, OutgoingEmail } from '../plugin-types';
|
||||
import type { Attachment, Email } from '../jmap/types';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import { detectBrowserLocale } from '@/i18n/detect-locale';
|
||||
import { emailHooks } from '../plugin-hooks';
|
||||
import { announceSent } from '../plugin-after-send';
|
||||
import { buildForwardSubject } from '../subject-prefix';
|
||||
import { finalizeOutgoing, runPreSendChecks } from '../outgoing/send-pipeline';
|
||||
// Registers the organisation signature check and the disclaimer.
|
||||
import '@/lib/org/outgoing';
|
||||
import { plainTextFromEmail } from '../plugin-projection';
|
||||
import { getByPath } from '../settings-search';
|
||||
import { readNamedEmail } from './host-api-mail';
|
||||
import { messagesFor } from './host-api-app';
|
||||
import { approvalAccount, forwardApprovals, saveForwardApprovals } from './forward-approvals';
|
||||
import { addressOf, readAddressList, readId, readObject, readString, sessionClient, withinRate } from './host-api-input';
|
||||
|
||||
/** Most recipients of one forward. */
|
||||
export const MAX_FORWARD_RECIPIENTS = 10;
|
||||
/** Forwards a plugin may send per minute. */
|
||||
export const MAX_FORWARDS_PER_MINUTE = 10;
|
||||
const MAX_NOTE = 64 * 1024;
|
||||
const MAX_SELECTED_ATTACHMENTS = 50;
|
||||
|
||||
// ─── Text in the user's language ──────────────────────────────
|
||||
|
||||
type Messages = Record<string, unknown>;
|
||||
|
||||
async function hostMessages(): Promise<Messages> {
|
||||
return messagesFor(useLocaleStore.getState().locale || detectBrowserLocale('en'));
|
||||
}
|
||||
|
||||
function text(messages: Messages, path: string, vars: Record<string, string> = {}): string {
|
||||
const value = getByPath(messages, path);
|
||||
const template = typeof value === 'string' ? value : path;
|
||||
return template.replace(/\{(\w+)\}/g, (whole, name: string) => vars[name] ?? whole);
|
||||
}
|
||||
|
||||
// ─── The forward ──────────────────────────────────────────────
|
||||
|
||||
export interface ForwardDeps {
|
||||
/** Shows a confirm the host draws for the plugin; resolves to the user's answer. */
|
||||
confirm: (input: { title: string; message: string; confirmLabel: string; cancelLabel: string }) => Promise<boolean>;
|
||||
/** Sets keywords on the original the way api.email.updateKeywords does. */
|
||||
markForwarded: (emailId: string, accountId: string | undefined) => Promise<unknown>;
|
||||
}
|
||||
|
||||
function formatAddress(entry: { name?: string | null; email?: string | null } | undefined): string {
|
||||
if (!entry?.email) return '';
|
||||
return entry.name ? `${entry.name} <${entry.email}>` : entry.email;
|
||||
}
|
||||
|
||||
function attachmentsOf(email: Email): Attachment[] {
|
||||
return (email.attachments ?? []).filter((a) => !!a.blobId);
|
||||
}
|
||||
|
||||
/** An inline image of the body, which a forward of the text leaves out. */
|
||||
function isInline(part: Attachment): boolean {
|
||||
return part.disposition === 'inline' && !!part.cid;
|
||||
}
|
||||
|
||||
function pickAttachments(email: Email, selection: unknown, method: string): Attachment[] {
|
||||
const all = attachmentsOf(email);
|
||||
if (selection === undefined) return all.filter((a) => !isInline(a));
|
||||
if (!Array.isArray(selection)) throw new Error(`${method}: attachments must be an array of blobIds`);
|
||||
if (selection.length > MAX_SELECTED_ATTACHMENTS) throw new Error(`${method}: at most ${MAX_SELECTED_ATTACHMENTS} attachments`);
|
||||
return [...new Set(selection.map((id, i) => readId(id, method, `attachments[${i}]`)))].map((blobId) => {
|
||||
const part = all.find((a) => a.blobId === blobId);
|
||||
if (!part) throw new Error(`${method}: ${blobId} is not an attachment of this message`);
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
/** The forwarded message as text: the note, the original's header lines and its text. */
|
||||
export function forwardBody(email: Email, note: string, messages: Messages, locale: string): string {
|
||||
const label = (key: string) => text(messages, `quote_header.${key}`);
|
||||
const date = email.receivedAt ? new Date(email.receivedAt) : null;
|
||||
const lines = [
|
||||
label('forwarded_separator'),
|
||||
`${label('from_label')}: ${(email.from ?? []).map(formatAddress).join(', ')}`,
|
||||
`${label('date_label')}: ${date && !Number.isNaN(date.getTime()) ? date.toLocaleString(locale) : ''}`,
|
||||
`${label('subject_label')}: ${email.subject ?? ''}`,
|
||||
];
|
||||
const to = (email.to ?? []).map(formatAddress).filter(Boolean).join(', ');
|
||||
if (to) lines.push(`${text(messages, 'email_viewer.to')}: ${to}`);
|
||||
const head = note.trim() ? `${note.trim()}\n\n` : '';
|
||||
return `${head}${lines.join('\n')}\n\n${plainTextFromEmail(email)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward `emailId` to `input.to` with the original's attachments. Resolves
|
||||
* to what happened; throws on bad input, a missing message or a failed send.
|
||||
*/
|
||||
export async function doEmailForward(
|
||||
plugin: InstalledPlugin,
|
||||
emailIdArg: unknown,
|
||||
inputArg: unknown,
|
||||
deps: ForwardDeps,
|
||||
): Promise<EmailForwardResult> {
|
||||
const method = 'email.forward';
|
||||
const emailId = readId(emailIdArg, method, 'emailId');
|
||||
const input = readObject(inputArg, method, 'forward', ['to', 'text', 'attachments', 'accountId']);
|
||||
const to = readAddressList(input.to, method, 'to', MAX_FORWARD_RECIPIENTS);
|
||||
if (to.length === 0) throw new Error(`${method}: to must name at least one address`);
|
||||
const note = input.text === undefined ? '' : readString(input.text, method, 'text', MAX_NOTE);
|
||||
if (!withinRate(method, plugin.id, MAX_FORWARDS_PER_MINUTE)) {
|
||||
throw new Error(`${method}: at most ${MAX_FORWARDS_PER_MINUTE} forwards a minute`);
|
||||
}
|
||||
|
||||
const client = sessionClient(method);
|
||||
const found = await readNamedEmail(client, emailId, method, input.accountId);
|
||||
if (!found) throw new Error(`${method}: no message with id "${emailId}"`);
|
||||
const { email, accountId: sourceAccountId } = found;
|
||||
const attachments = pickAttachments(email, input.attachments, method);
|
||||
|
||||
const { identities, preferredPrimaryId } = useIdentityStore.getState();
|
||||
const identity = identities.find((i) => i.id === preferredPrimaryId) ?? identities[0];
|
||||
if (!identity) throw new Error(`${method}: the account has no sender address to forward from`);
|
||||
|
||||
const locale = useLocaleStore.getState().locale || detectBrowserLocale('en');
|
||||
const messages = await hostMessages();
|
||||
const subject = buildForwardSubject(email.subject || text(messages, 'email_viewer.no_subject'), text(messages, 'email_composer.prefix.forward'));
|
||||
|
||||
// Ask before the first forward to an address.
|
||||
const addresses = to.map((entry) => addressOf(entry).toLowerCase());
|
||||
const account = approvalAccount();
|
||||
const approved = new Set(forwardApprovals(plugin.id, account));
|
||||
const unknown = [...new Set(addresses.filter((a) => !approved.has(a)))];
|
||||
if (unknown.length > 0) {
|
||||
const vars = { plugin: plugin.name || plugin.id, addresses: unknown.join(', '), subject: email.subject || text(messages, 'email_viewer.no_subject') };
|
||||
const allowed = await deps.confirm({
|
||||
title: text(messages, 'plugins.forward_approval.title', vars),
|
||||
message: text(messages, 'plugins.forward_approval.message', vars),
|
||||
confirmLabel: text(messages, 'plugins.forward_approval.allow'),
|
||||
cancelLabel: text(messages, 'plugins.dialog.cancel'),
|
||||
});
|
||||
if (!allowed) return { sent: false, reason: 'declined' };
|
||||
saveForwardApprovals(plugin.id, account, [...approved, ...unknown]);
|
||||
}
|
||||
|
||||
const context = {
|
||||
surface: 'plugin-forward' as const,
|
||||
to: addresses,
|
||||
cc: [],
|
||||
bcc: [],
|
||||
fromEmail: identity.email,
|
||||
identityId: identity.id,
|
||||
sender: { name: identity.name, email: identity.email },
|
||||
mode: 'forward' as const,
|
||||
plainText: true,
|
||||
forwardedSeparator: text(messages, 'quote_header.forwarded_separator'),
|
||||
};
|
||||
if (!(await runPreSendChecks(context))) return { sent: false, reason: 'stopped' };
|
||||
// No plugin step in between, as for a quick reply: both stages at once.
|
||||
const body = finalizeOutgoing(finalizeOutgoing({ text: forwardBody(email, note, messages, locale) }, context), context, 'last').text;
|
||||
|
||||
if (emailHooks.onBeforeEmailSend.size > 0) {
|
||||
const outgoing: OutgoingEmail = {
|
||||
to, cc: [], bcc: [], subject,
|
||||
htmlBody: '',
|
||||
textBody: body,
|
||||
identityId: identity.id,
|
||||
fromEmail: identity.email,
|
||||
attachments: attachments.map((a) => ({ name: a.name || 'attachment', type: a.type || 'application/octet-stream', size: a.size })),
|
||||
};
|
||||
if ((await emailHooks.onBeforeEmailSend.interceptOutcome(outgoing)) !== 'allowed') return { sent: false, reason: 'stopped' };
|
||||
}
|
||||
|
||||
// The attachments are blobs of the account the original is in; a message
|
||||
// of a team or shared mailbox goes out from the user's own account.
|
||||
const sendAccountId = (await client.resolveSendAccountId?.(identity.email)) ?? client.getAccountId();
|
||||
const fromAccountId = sourceAccountId ?? client.getAccountId();
|
||||
let blobIds = attachments.map((a) => a.blobId);
|
||||
if (blobIds.length && fromAccountId !== sendAccountId) {
|
||||
const copied = await client.copyBlobs(fromAccountId, blobIds, sendAccountId);
|
||||
blobIds = blobIds.map((id) => {
|
||||
const copy = copied[id];
|
||||
if (!copy) throw new Error(`${method}: the attachments could not be copied to the account that sends`);
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
|
||||
const delaySeconds = useSettingsStore.getState().sendDelaySeconds;
|
||||
const delayedUntil = delaySeconds > 0 && client.hasDelayedSend()
|
||||
? new Date(Date.now() + delaySeconds * 1000).toISOString()
|
||||
: undefined;
|
||||
const result = await useEmailStore.getState().sendEmail(
|
||||
client, to, subject, body, undefined, undefined, identity.id, identity.email, undefined,
|
||||
identity.name || undefined, undefined,
|
||||
attachments.map((a, i) => ({
|
||||
blobId: blobIds[i],
|
||||
name: a.name || 'attachment',
|
||||
type: a.type || 'application/octet-stream',
|
||||
size: a.size,
|
||||
disposition: 'attachment' as const,
|
||||
})),
|
||||
undefined, undefined, delayedUntil,
|
||||
);
|
||||
announceSent(result, { to: addresses, cc: [], bcc: [], subject });
|
||||
|
||||
try {
|
||||
await deps.markForwarded(emailId, sourceAccountId);
|
||||
} catch {
|
||||
// The forward went out; a missing $forwarded mark is not worth failing it.
|
||||
}
|
||||
return {
|
||||
sent: true,
|
||||
emailId: result.emailId ?? null,
|
||||
to: addresses,
|
||||
scheduledFor: result.scheduled && result.sendAt ? result.sendAt : null,
|
||||
};
|
||||
}
|
||||
@@ -66,6 +66,23 @@ async function sourceAccount(client: IJMAPClient, emailId: string, method: strin
|
||||
return listed ? accountId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The message a plugin named, read the way email.get reads it, with the
|
||||
* account it is in (undefined: the signed-in account). Null when there is
|
||||
* no such message this login may read.
|
||||
*/
|
||||
export async function readNamedEmail(
|
||||
client: IJMAPClient,
|
||||
emailId: string,
|
||||
method: string,
|
||||
accountArg?: unknown,
|
||||
): Promise<{ email: Email; accountId: string | undefined } | null> {
|
||||
const accountId = await sourceAccount(client, emailId, method, accountArg);
|
||||
if (accountId === null) return null;
|
||||
const email = await loadEmail(client, emailId, accountId, accountArg !== undefined);
|
||||
return email ? { email, accountId } : null;
|
||||
}
|
||||
|
||||
export async function doEmailGet(id: unknown, accountArg?: unknown): Promise<EmailReadView | null> {
|
||||
const method = 'email.get';
|
||||
const emailId = readId(id, method, 'emailId');
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Host side of api.ui.print: print a page a plugin made, such as an
|
||||
// invoice drawn from its XML. A plugin frame can't print (its sandbox has
|
||||
// no allow-modals, and the user would get the whole frame), so the host
|
||||
// prints the page from a hidden frame of its own.
|
||||
//
|
||||
// The page is treated like a message body: sanitised as for the reader
|
||||
// (styles allowed, no scripts, forms or frames), shown in a frame that
|
||||
// may not run scripts, under the reader's strict CSP, so it loads nothing
|
||||
// from the network. Images have to come as data: URLs.
|
||||
|
||||
import type { InstalledPlugin } from '../plugin-types';
|
||||
import { emailIframeCsp, escapeHtml, sanitizeEmailHtmlForIframe } from '../email-sanitization';
|
||||
import { readObject, readString, withinRate } from './host-api-input';
|
||||
|
||||
const MAX_TITLE = 200;
|
||||
const MAX_HTML = 2 * 1024 * 1024;
|
||||
/** Print dialogs a plugin may open per minute. */
|
||||
export const MAX_PRINTS_PER_MINUTE = 3;
|
||||
/** How long the frame stays when the browser never says printing ended. */
|
||||
const CLEANUP_MS = 60_000;
|
||||
|
||||
/** The document the frame prints. Exported for tests. */
|
||||
export function printDocument(title: string, html: string, dir: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html dir="${dir === 'rtl' ? 'rtl' : 'ltr'}"><head><meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="${emailIframeCsp(true)}">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #000; background: #fff; margin: 0; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
@page { margin: 15mm; }
|
||||
</style></head><body>${sanitizeEmailHtmlForIframe(html)}</body></html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the browser's print dialog for `page.html`. Resolves to true once
|
||||
* the dialog was asked for (the user may still cancel it there).
|
||||
*/
|
||||
export async function doUiPrint(plugin: InstalledPlugin, input: unknown): Promise<boolean> {
|
||||
const method = 'ui.print';
|
||||
const page = readObject(input, method, 'page', ['title', 'html']);
|
||||
const title = page.title === undefined ? plugin.name || plugin.id : readString(page.title, method, 'title', MAX_TITLE);
|
||||
// eslint-disable-next-line no-control-regex -- a title is one line
|
||||
if (/[\u0000-\u001f\u007f]/.test(title)) throw new Error(`${method}: title must be one line`);
|
||||
const html = readString(page.html, method, 'html', MAX_HTML);
|
||||
if (!withinRate(method, plugin.id, MAX_PRINTS_PER_MINUTE)) {
|
||||
throw new Error(`${method}: at most ${MAX_PRINTS_PER_MINUTE} times a minute`);
|
||||
}
|
||||
|
||||
const frame = document.createElement('iframe');
|
||||
// Same origin so the host can call print() on it; no allow-scripts, so
|
||||
// nothing in the page runs.
|
||||
frame.setAttribute('sandbox', 'allow-same-origin allow-modals');
|
||||
frame.setAttribute('aria-hidden', 'true');
|
||||
frame.tabIndex = -1;
|
||||
// Off screen but laid out: some browsers print an empty page from a
|
||||
// frame with display: none.
|
||||
frame.style.cssText = 'position:fixed;left:-10000px;top:0;width:800px;height:600px;border:0;visibility:hidden;';
|
||||
frame.srcdoc = printDocument(title, html, document.documentElement.dir);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
frame.addEventListener('load', () => resolve(), { once: true });
|
||||
frame.addEventListener('error', () => reject(new Error(`${method}: the page could not be prepared`)), { once: true });
|
||||
document.body.appendChild(frame);
|
||||
});
|
||||
|
||||
const view = frame.contentWindow;
|
||||
if (!view) {
|
||||
frame.remove();
|
||||
throw new Error(`${method}: the page could not be prepared`);
|
||||
}
|
||||
const remove = () => frame.remove();
|
||||
view.addEventListener('afterprint', remove, { once: true });
|
||||
setTimeout(remove, CLEANUP_MS);
|
||||
view.focus();
|
||||
view.print();
|
||||
return true;
|
||||
}
|
||||
@@ -57,6 +57,8 @@ import {
|
||||
} from './host-api-mail';
|
||||
import { MAX_PLUGIN_FETCH_BYTES, readFileName, readMediaType, sessionClient } from './host-api-input';
|
||||
import { doComposeOpen, doComposeReply, doDraftsCreate } from './host-api-compose';
|
||||
import { doEmailForward } from './host-api-forward';
|
||||
import { doUiPrint } from './host-api-print';
|
||||
import { doNotificationsShow } from './host-api-notify';
|
||||
import { doAccountDataGet, doAccountDataSet, doUserFindPeople, doUserGetMailAccounts, doUserGetSelf } from './host-api-account';
|
||||
import { setSectionTitle } from './section-titles';
|
||||
@@ -191,6 +193,8 @@ export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
|
||||
'ui.rerenderEmail': null,
|
||||
'ui.rerenderFetchedEmails': null,
|
||||
'ui.openExternalUrl': null,
|
||||
// Print a page the plugin made (sanitised, no scripts, no network); 3 a minute.
|
||||
'ui.print': null,
|
||||
'ui.downloadFile': 'ui:download-file',
|
||||
// email keyword mutations
|
||||
'email.setKeyword': 'email:write',
|
||||
@@ -288,6 +292,9 @@ export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
|
||||
// Moving messages between the account's folders, archive included.
|
||||
'email.move': 'email:write',
|
||||
'email.archive': 'email:write',
|
||||
// Forward a message with its attachments, without the composer. The
|
||||
// host asks the user before the first forward to each address.
|
||||
'email.forward': 'email:forward',
|
||||
// Open the composer filled in; the user still decides whether to send.
|
||||
'compose.open': 'compose:open',
|
||||
// Reply to the message open in the reader, with text to start from.
|
||||
@@ -2198,6 +2205,7 @@ export async function dispatchApiCall(
|
||||
window.open(parsed.toString(), '_blank', 'noopener,noreferrer');
|
||||
return undefined;
|
||||
}
|
||||
case 'ui.print': return doUiPrint(plugin, args[0]);
|
||||
case 'ui.downloadFile': {
|
||||
const opts = args[0] as { content: string | ArrayBuffer | Uint8Array; filename: string; contentType?: string };
|
||||
return downloadFile(opts ?? { content: '', filename: '' });
|
||||
@@ -2300,6 +2308,10 @@ export async function dispatchApiCall(
|
||||
case 'email.getRaw': return doEmailGetRaw(args[0], args[1]);
|
||||
case 'email.move': return doEmailMove(plugin.id, args[0], args[1]);
|
||||
case 'email.archive': return doEmailArchive(plugin.id, args[0]);
|
||||
case 'email.forward': return doEmailForward(plugin, args[0], args[1], {
|
||||
confirm: (input) => awaitDialog({ pluginId: plugin.id, kind: 'confirm', ...input }),
|
||||
markForwarded: (emailId, accountId) => doEmailUpdateKeywords(emailId, { $forwarded: true }, accountId),
|
||||
});
|
||||
|
||||
case 'compose.open': return doComposeOpen(plugin, args[0]);
|
||||
case 'compose.reply': return doComposeReply(plugin, args[0], args[1]);
|
||||
|
||||
@@ -15,6 +15,8 @@ export const HIGH_RISK_PERMISSIONS: ReadonlySet<string> = new Set([
|
||||
'email:read',
|
||||
'email:send',
|
||||
'email:raw-send',
|
||||
// Sends mail in the user's name; the host asks once per address.
|
||||
'email:forward',
|
||||
'email:blob-read',
|
||||
'email:blob-write',
|
||||
'email:render-takeover',
|
||||
|
||||
@@ -18,14 +18,27 @@ export function subscribeRowBadges(listener: () => void): () => void {
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
/** Ask every visible row again. At most once a second; extra calls are dropped. */
|
||||
export function refreshRowBadges(now = Date.now()): boolean {
|
||||
if (now - lastRefresh < ROW_BADGE_REFRESH_INTERVAL_MS) return false;
|
||||
lastRefresh = now;
|
||||
function bump(): void {
|
||||
generation += 1;
|
||||
for (const l of listeners) {
|
||||
try { l(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin's onEmailListItemRender handler came or went: ask every row
|
||||
* again. The list usually renders before the plugins have started, and
|
||||
* those rows would otherwise never show the plugin's badges.
|
||||
*/
|
||||
export function rowBadgeHandlersChanged(): void {
|
||||
bump();
|
||||
}
|
||||
|
||||
/** Ask every visible row again. At most once a second; extra calls are dropped. */
|
||||
export function refreshRowBadges(now = Date.now()): boolean {
|
||||
if (now - lastRefresh < ROW_BADGE_REFRESH_INTERVAL_MS) return false;
|
||||
lastRefresh = now;
|
||||
bump();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import { initFloatingDropdownBridge, dispatchFloatingDropdownEvent } from './ui-
|
||||
import { interpolate, lookup, textDirection, type LocaleTables } from './plugin-i18n';
|
||||
import { isFillableSlot, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout';
|
||||
import type {
|
||||
AppInfoView, AppSettingsView, HostFeaturesView, SettingsSearchResult, CalendarEventInput, CalendarEventView, CalendarView, ComposeInput, DraftInput, EmailReadView,
|
||||
AppInfoView, AppSettingsView, HostFeaturesView, SettingsSearchResult, CalendarEventInput, CalendarEventView, CalendarView, ComposeInput, DraftInput, EmailForwardInput, EmailForwardResult, EmailReadView,
|
||||
EmailSummary, IdentityPatch, IdentityView, MailboxView, NotificationInput, SlotName, TaskInput,
|
||||
TaskView, TemplateInput, TemplateView, VacationInput, VacationView,
|
||||
} from '../plugin-types';
|
||||
@@ -630,6 +630,13 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
/** Opens an http/https URL in a new tab via host `window.open`. */
|
||||
openExternalUrl: (url: string, target?: string) =>
|
||||
callApi('ui.openExternalUrl', [url, target]) as Promise<void>,
|
||||
/**
|
||||
* Print a page the plugin made, such as a document drawn from data:
|
||||
* HTML with <style>, sanitised like a message body, no scripts, images
|
||||
* as data: URLs only. Opens the browser's print dialog (where the user
|
||||
* can also save a PDF); 3 times a minute.
|
||||
*/
|
||||
print: (page: { title?: string; html: string }) => callApi('ui.print', [page], 0) as Promise<boolean>,
|
||||
/** Downloads a file generated by the plugin. Not a user's file or attachment. */
|
||||
/** `content` is text, or the file's bytes (ArrayBuffer / Uint8Array). */
|
||||
downloadFile: (opts: { content: string | ArrayBuffer | Uint8Array; filename: string; contentType?: string }) =>
|
||||
@@ -767,6 +774,15 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
*/
|
||||
updateKeywords: (emailId: string, changes: Record<string, boolean>, accountId?: string) =>
|
||||
callApi('email.updateKeywords', [emailId, changes, accountId]) as Promise<boolean>,
|
||||
/**
|
||||
* Forward a message with its attachments to 1-10 addresses, without
|
||||
* the composer (email:forward). The first forward to an address asks
|
||||
* the user; resolves to { sent: false, reason: 'declined' } when they
|
||||
* say no and 'stopped' when a send check stopped it. 10 a minute.
|
||||
* Waits as long as the user takes to answer.
|
||||
*/
|
||||
forward: (emailId: string, input: EmailForwardInput) =>
|
||||
callApi('email.forward', [emailId, input], 0) as Promise<EmailForwardResult>,
|
||||
},
|
||||
// A small JSON value (at most 4 KB) the plugin keeps in a mail account's
|
||||
// Inbox, shared with everyone who can open that account (Stalwart 1.0
|
||||
|
||||
@@ -52,7 +52,8 @@ export function claimLegacyPluginStorage(pluginId: string, accountId: string): v
|
||||
const moves: [string, string][] = [];
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const key = window.localStorage.key(i);
|
||||
if (key?.startsWith(legacy) && !key.startsWith(`${legacy}@`)) {
|
||||
// `@`: already per account; `!`: the host's own (hostOnlyPluginKey).
|
||||
if (key?.startsWith(legacy) && !key.startsWith(`${legacy}@`) && !key.startsWith(`${legacy}!`)) {
|
||||
moves.push([key, scoped + key.slice(legacy.length)]);
|
||||
}
|
||||
}
|
||||
@@ -66,13 +67,29 @@ export function claimLegacyPluginStorage(pluginId: string, accountId: string): v
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the host keeps something about a plugin that the plugin must not
|
||||
* change, such as the addresses the user let it forward mail to:
|
||||
* `plugin:<pluginId>:!<name>:@<accountId>:`. A plugin's own keys always have
|
||||
* `@` right after its id, so api.storage can't reach these, and removing the
|
||||
* plugin or signing the account out still removes them.
|
||||
*/
|
||||
export function hostOnlyPluginKey(pluginId: string, accountId: string, name: string): string {
|
||||
return `${PLUGIN_PREFIX}${pluginId}:!${name}:${accountTag(accountId)}`;
|
||||
}
|
||||
|
||||
/** Delete what every plugin stored for this account. */
|
||||
export function clearPluginStorageForAccount(accountId: string): void {
|
||||
const tag = accountTag(accountId);
|
||||
removeKeys((key) => {
|
||||
if (!key.startsWith(PLUGIN_PREFIX)) return false;
|
||||
const idEnd = key.indexOf(':', PLUGIN_PREFIX.length);
|
||||
return idEnd > 0 && key.startsWith(tag, idEnd + 1);
|
||||
if (idEnd <= 0) return false;
|
||||
if (key.startsWith(tag, idEnd + 1)) return true;
|
||||
// A host-only key: `!<name>:` before the account tag.
|
||||
if (key[idEnd + 1] !== '!') return false;
|
||||
const nameEnd = key.indexOf(':', idEnd + 2);
|
||||
return nameEnd > 0 && key.startsWith(tag, nameEnd + 1);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1363,6 +1363,81 @@ export function DetailRow({ label, children, mono = false }: { label: React.Reac
|
||||
);
|
||||
}
|
||||
|
||||
export interface KitTableColumn {
|
||||
/** The key of the cell in each row. */
|
||||
key: string;
|
||||
label: React.ReactNode;
|
||||
/** `end` for amounts and numbers: right-aligned (left in RTL), in tabular figures. */
|
||||
align?: 'start' | 'end';
|
||||
/** Hidden when the frame is narrower than 640 px (a phone). */
|
||||
hideNarrow?: boolean;
|
||||
/** A fixed width such as `6rem`; the other columns share the rest. */
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export interface KitTableSummaryRow {
|
||||
label: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
/** The line that matters most, such as the amount due. */
|
||||
strong?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A table of data: a header row, one row per entry, and summary rows under
|
||||
* it whose label lines up with the last column (totals of an invoice).
|
||||
* Rows are objects keyed by column key; `key` names the row for React.
|
||||
* Wider than the frame, it scrolls sideways.
|
||||
*/
|
||||
export function Table({ columns, rows, summary, label, empty }: {
|
||||
columns: KitTableColumn[];
|
||||
rows: (Record<string, React.ReactNode> & { key?: string | number })[];
|
||||
summary?: KitTableSummaryRow[];
|
||||
/** The table's name for screen readers. */
|
||||
label?: string;
|
||||
/** Shown in place of the rows when there are none. */
|
||||
empty?: React.ReactNode;
|
||||
}) {
|
||||
const cell = (c: KitTableColumn) => cn(
|
||||
'px-3 align-top',
|
||||
c.align === 'end' ? 'text-end tabular-nums whitespace-nowrap' : 'text-start',
|
||||
c.hideNarrow && 'hidden sm:table-cell',
|
||||
);
|
||||
const visibleSpan = columns.length - 1;
|
||||
return (
|
||||
<div className="bk overflow-x-auto">
|
||||
<table className="w-full border-collapse text-sm" aria-label={label}>
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs font-medium text-muted-foreground">
|
||||
{columns.map((c) => (
|
||||
<th key={c.key} scope="col" className={cn(cell(c), 'py-2 font-medium')} style={c.width ? { width: c.width } : undefined}>{c.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && empty !== undefined ? (
|
||||
<tr><td colSpan={columns.length} className="px-3 py-3 text-muted-foreground">{empty}</td></tr>
|
||||
) : rows.map((row, i) => (
|
||||
<tr key={row.key ?? i} className="border-b border-border/60">
|
||||
{columns.map((c) => <td key={c.key} className={cn(cell(c), 'py-2 text-foreground break-words')}>{row[c.key]}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{summary && summary.length > 0 && (
|
||||
<tfoot>
|
||||
{summary.map((s, i) => (
|
||||
<tr key={i} className={cn(s.strong && 'border-t border-border')}>
|
||||
{/* On phones the hideNarrow columns are gone; the label still spans what is left. */}
|
||||
<th scope="row" colSpan={visibleSpan} className={cn('px-3 text-end font-normal', s.strong ? 'py-2 font-semibold text-foreground' : 'py-1 text-muted-foreground')}>{s.label}</th>
|
||||
<td className={cn('px-3 text-end tabular-nums whitespace-nowrap', s.strong ? 'py-2 font-semibold text-foreground' : 'py-1 text-foreground')}>{s.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A small round dot in a colour the data carries, like a calendar's or a
|
||||
* tag's colour. Only #rgb/#rrggbb values are used; anything else shows the
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
// plugin-ui-kit.test.ts checks this list against the kit.
|
||||
|
||||
/** Bumped when a component is added or its props change in a way plugins can see. */
|
||||
export const KIT_VERSION = 2;
|
||||
export const KIT_VERSION = 3;
|
||||
|
||||
export const KIT_NAMES: readonly string[] = Object.freeze([
|
||||
'Alert', 'Badge', 'BarButton', 'Breadcrumbs', 'Button', 'Card', 'Checkbox', 'Chip', 'ColorDot', 'Combobox',
|
||||
'DetailHeading', 'DetailList', 'DetailRow', 'DialogButtons', 'DiffView', 'Divider', 'EmptyState', 'Field',
|
||||
'FileIcon', 'Heading', 'Icon', 'InfoBar', 'Input', 'KIT_VERSION', 'Link', 'LinkButton', 'List', 'ListRow',
|
||||
'Loading', 'Page', 'PageHeader', 'Panel', 'ProgressBar', 'Row', 'RowAction', 'SearchInput', 'Section',
|
||||
'Segmented', 'Select', 'SettingRow', 'SidebarEmpty', 'SidebarRow', 'Spinner', 'Stack', 'Switch', 'Tabs',
|
||||
'Segmented', 'Select', 'SettingRow', 'SidebarEmpty', 'SidebarRow', 'Spinner', 'Stack', 'Switch', 'Table', 'Tabs',
|
||||
'Text', 'Textarea', 'Toolbar', 'ToolbarButton', 'ToolbarSpacer', 'useSlotProps',
|
||||
]);
|
||||
|
||||
@@ -736,6 +736,34 @@ export interface ComposeInput {
|
||||
/** What a plugin passes to `api.drafts.create`: the same fields as ComposeInput. */
|
||||
export type DraftInput = ComposeInput;
|
||||
|
||||
/**
|
||||
* What a plugin passes to `api.email.forward`. The host builds the forward
|
||||
* (subject with the forward prefix, the plugin's note, the original's
|
||||
* header lines and text) and sends it from the user's default address.
|
||||
*/
|
||||
export interface EmailForwardInput {
|
||||
/** 1-10 recipients, `a@example.com` or `Name <a@example.com>`. */
|
||||
to: string[];
|
||||
/** A note above the forwarded message, plain text, up to 64 KB. */
|
||||
text?: string;
|
||||
/**
|
||||
* Which attachments of the original go along, by blobId (attachments of
|
||||
* that message only). Default: every attachment that isn't an inline image.
|
||||
*/
|
||||
attachments?: string[];
|
||||
/** The account the message is in, as on EmailReadView (team and shared mailboxes). */
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What `api.email.forward` resolves to. `declined`: the user didn't let the
|
||||
* plugin forward to a new address; `stopped`: a pre-send check or another
|
||||
* plugin's onBeforeEmailSend stopped the send.
|
||||
*/
|
||||
export type EmailForwardResult =
|
||||
| { sent: true; emailId: string | null; to: string[]; scheduledFor: string | null }
|
||||
| { sent: false; reason: 'declined' | 'stopped' };
|
||||
|
||||
/**
|
||||
* What a plugin passes to `api.notifications.show`. The plugin's name is
|
||||
* added to what the user sees, so they know who sent it.
|
||||
@@ -1345,6 +1373,9 @@ export const ALL_PERMISSIONS = [
|
||||
'compose:read',
|
||||
// Change the message being written (api.composer.replaceSelection/replaceBody).
|
||||
'compose:write',
|
||||
// Forward a message of the account with its attachments, without the
|
||||
// composer (api.email.forward). The host asks the user once per address.
|
||||
'email:forward',
|
||||
'calendar:read', 'calendar:write',
|
||||
'contacts:read', 'contacts:write',
|
||||
'files:read', 'files:write',
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "الحسابات المرتبطة",
|
||||
"account_on": "{label} على {host}",
|
||||
"account_waiting": "{label} على {host} (لم يكتمل تسجيل الدخول)",
|
||||
"forward_heading": "يعيد توجيه البريد إلى",
|
||||
"forward_hint": "عناوين يمكن لهذه الإضافة إعادة توجيه الرسائل إليها دون أن تسأل.",
|
||||
"forward_remove": "إزالة",
|
||||
"forward_remove_label": "إيقاف إعادة التوجيه إلى {address}",
|
||||
"disconnect": "فصل",
|
||||
"disconnect_failed": "تعذّر فصل {label}"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "تعديل الرسالة التي تكتبها",
|
||||
"body": "استبدال نص أو إضافته في الرسالة أثناء كتابتها، عندما تستخدم الإضافة في محرر الرسائل. يمكنك التراجع عن كل تغيير، وأنت من يقرر الإرسال."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "إعادة توجيه بريدك",
|
||||
"body": "إعادة توجيه رسائل من حسابك مع مرفقاتها دون فتح محرر الرسائل. يُطلب إذنك قبل أول إعادة توجيه إلى كل عنوان."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "عرض الإشعارات",
|
||||
"body": "عرض إشعارات على جهازك، أو داخل التطبيق إذا لم تسمح بها. يذكر كل إشعار اسم الإضافة."
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "إرسال طلبات عبر خادم {appName} باستخدام حساب أو مفتاح أعددته أنت أو مسؤول النظام. لا ترى الإضافة كلمة المرور أبدًا، لكنها تستطيع فعل أي شيء يستطيعه ذلك الحساب على خادمه."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "هل تسمح لـ {plugin} بإعادة توجيه البريد إلى {addresses}؟",
|
||||
"message": "يريد {plugin} إعادة توجيه «{subject}» مع مرفقاتها إلى {addresses}. إذا سمحت بذلك، فسيتمكن من الآن فصاعدًا من إعادة توجيه البريد إلى هذا العنوان دون أن يسألك. يمكنك التراجع عن ذلك من الإعدادات > الإضافات.",
|
||||
"allow": "السماح وإعادة التوجيه"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "من الإضافة {name}",
|
||||
"ok": "حسنًا",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "Comptes connectats",
|
||||
"account_on": "{label} a {host}",
|
||||
"account_waiting": "{label} a {host} (inici de sessió sense acabar)",
|
||||
"forward_heading": "Reenvia el correu a",
|
||||
"forward_hint": "Adreces a les quals aquest connector pot reenviar missatges sense preguntar.",
|
||||
"forward_remove": "Elimina",
|
||||
"forward_remove_label": "Deixa de reenviar a {address}",
|
||||
"disconnect": "Desconnecta",
|
||||
"disconnect_failed": "No s'ha pogut desconnectar {label}"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "Canviar el missatge que escriviu",
|
||||
"body": "Substituir o afegir text en un missatge mentre l'escriviu, quan feu servir el connector a l'editor. Podeu desfer cada canvi i continueu decidint si l'envieu."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "Reenviar el vostre correu",
|
||||
"body": "Reenviar missatges del vostre compte amb els adjunts, sense obrir l'editor. Se us pregunta abans del primer reenviament a cada adreça."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Mostrar notificacions",
|
||||
"body": "Mostrar notificacions al dispositiu, o dins l'aplicació si no les heu permeses. Cadascuna indica el nom del connector."
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "Enviar peticions a través del servidor de {appName} amb un compte o una clau que heu configurat vós o el vostre administrador. El connector no veu mai la contrasenya, però pot fer tot el que aquest compte pot fer al seu propi servidor."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "Voleu permetre que {plugin} reenviï correu a {addresses}?",
|
||||
"message": "{plugin} vol reenviar «{subject}» amb els adjunts a {addresses}. Si ho permeteu, d'ara endavant podrà reenviar correu a aquesta adreça sense preguntar. Ho podeu revocar a Configuració > Complements.",
|
||||
"allow": "Permet i reenvia"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "Del connector {name}",
|
||||
"ok": "D'acord",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "Připojené účty",
|
||||
"account_on": "{label} na {host}",
|
||||
"account_waiting": "{label} na {host} (přihlášení nedokončeno)",
|
||||
"forward_heading": "Přeposílá poštu na",
|
||||
"forward_hint": "Adresy, na které smí tento plugin přeposílat zprávy bez ptaní.",
|
||||
"forward_remove": "Odebrat",
|
||||
"forward_remove_label": "Přestat přeposílat na {address}",
|
||||
"disconnect": "Odpojit",
|
||||
"disconnect_failed": "{label} se nepodařilo odpojit"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "Měnit zprávu, kterou píšete",
|
||||
"body": "Nahrazovat nebo přidávat text ve zprávě během psaní, když plugin použijete v editoru. Každou změnu můžete vrátit a o odeslání stále rozhodujete vy."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "Přeposílat vaši poštu",
|
||||
"body": "Přeposílá zprávy z vašeho účtu i s přílohami, aniž by otevřel editor. Před prvním přeposláním na každou adresu se vás zeptá."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Zobrazovat oznámení",
|
||||
"body": "Zobrazuje oznámení ve vašem zařízení, nebo v aplikaci, pokud jste je nepovolili. Každé uvádí název pluginu."
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "Odesílá požadavky přes server aplikace {appName} pomocí účtu nebo klíče, který jste nastavili vy nebo váš správce. Plugin nikdy nevidí heslo, ale může na příslušném serveru dělat cokoli, co může tento účet."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "Povolit pluginu {plugin} přeposílat poštu na {addresses}?",
|
||||
"message": "{plugin} chce přeposlat „{subject}“ i s přílohami na {addresses}. Pokud to povolíte, bude od teď moci přeposílat poštu na tuto adresu bez ptaní. Odvolat to můžete v Nastavení > Doplňky.",
|
||||
"allow": "Povolit a přeposlat"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "Od pluginu {name}",
|
||||
"ok": "OK",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "Forbundne konti",
|
||||
"account_on": "{label} på {host}",
|
||||
"account_waiting": "{label} på {host} (login ikke afsluttet)",
|
||||
"forward_heading": "Videresender post til",
|
||||
"forward_hint": "Adresser, som dette plugin må videresende beskeder til uden at spørge.",
|
||||
"forward_remove": "Fjern",
|
||||
"forward_remove_label": "Stop videresendelse til {address}",
|
||||
"disconnect": "Afbryd forbindelse",
|
||||
"disconnect_failed": "Kunne ikke afbryde forbindelsen til {label}"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "Ændre den besked, du skriver",
|
||||
"body": "Erstatte eller tilføje tekst i en besked, mens du skriver den, når du bruger pluginet i editoren. Du kan fortryde hver ændring, og du bestemmer stadig, om den sendes."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "Videresende din post",
|
||||
"body": "Videresende beskeder fra din konto med vedhæftninger uden at åbne editoren. Du bliver spurgt før første videresendelse til hver adresse."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Vise notifikationer",
|
||||
"body": "Vise notifikationer på din enhed eller i appen, hvis du ikke har tilladt dem. Hver af dem nævner pluginets navn."
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "Sende forespørgsler via {appName}s server med en konto eller nøgle, som du eller din administrator har sat op. Pluginnet ser aldrig adgangskoden, men det kan gøre alt, hvad kontoen kan gøre på sin egen server."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "Tillad {plugin} at videresende post til {addresses}?",
|
||||
"message": "{plugin} vil videresende “{subject}” med vedhæftninger til {addresses}. Hvis du tillader det, kan den fremover videresende post til denne adresse uden at spørge. Du kan trække det tilbage under Indstillinger > Tilføjelser.",
|
||||
"allow": "Tillad og videresend"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "Fra pluginet {name}",
|
||||
"ok": "OK",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "Verbundene Konten",
|
||||
"account_on": "{label} auf {host}",
|
||||
"account_waiting": "{label} auf {host} (Anmeldung nicht abgeschlossen)",
|
||||
"forward_heading": "Leitet E-Mails weiter an",
|
||||
"forward_hint": "Adressen, an die dieses Plugin ohne Nachfrage Nachrichten weiterleiten darf.",
|
||||
"forward_remove": "Entfernen",
|
||||
"forward_remove_label": "Weiterleitung an {address} beenden",
|
||||
"disconnect": "Trennen",
|
||||
"disconnect_failed": "{label} konnte nicht getrennt werden"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "Die Nachricht ändern, die Sie schreiben",
|
||||
"body": "Text in einer Nachricht ersetzen oder ergänzen, während Sie sie schreiben, wenn Sie das Plugin im Editor benutzen. Jede Änderung lässt sich rückgängig machen, und Sie entscheiden weiterhin, ob Sie senden."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "E-Mails weiterleiten",
|
||||
"body": "Nachrichten aus Ihrem Konto samt Anhängen weiterleiten, ohne den Editor zu öffnen. Vor der ersten Weiterleitung an eine Adresse werden Sie gefragt."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Benachrichtigungen anzeigen",
|
||||
"body": "Benachrichtigungen auf Ihrem Gerät anzeigen oder in der App, wenn Sie sie nicht erlaubt haben. Jede nennt den Namen des Plugins."
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "Anfragen über den Server von {appName} senden, mit einem Konto oder Schlüssel, das Sie oder Ihr Administrator eingerichtet haben. Das Plugin sieht das Passwort nie, kann auf dem zugehörigen Server aber alles tun, was dieses Konto darf."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "{plugin} erlauben, E-Mails an {addresses} weiterzuleiten?",
|
||||
"message": "{plugin} möchte „{subject}“ samt Anhängen an {addresses} weiterleiten. Wenn Sie das erlauben, kann es künftig ohne Nachfrage E-Mails an diese Adresse weiterleiten. Unter Einstellungen > Erweiterungen können Sie das zurücknehmen.",
|
||||
"allow": "Erlauben und weiterleiten"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "Vom Plugin {name}",
|
||||
"ok": "OK",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "Connected accounts",
|
||||
"account_on": "{label} on {host}",
|
||||
"account_waiting": "{label} on {host} (sign-in not finished)",
|
||||
"forward_heading": "Forwards mail to",
|
||||
"forward_hint": "Addresses this plugin may forward messages to without asking.",
|
||||
"forward_remove": "Remove",
|
||||
"forward_remove_label": "Stop forwarding to {address}",
|
||||
"disconnect": "Disconnect",
|
||||
"disconnect_failed": "Could not disconnect {label}"
|
||||
},
|
||||
@@ -5692,11 +5696,20 @@
|
||||
"title": "Change the message you are writing",
|
||||
"body": "Replace or add text in a message while you write it, when you use the plugin in the composer. You can undo each change, and you still decide whether to send."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "Forward your mail",
|
||||
"body": "Forward messages from your account with their attachments, without opening the composer. You are asked before the first forward to each address."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Show notifications",
|
||||
"body": "Show notifications on your device, or in the app if you haven't allowed them. Each one names the plugin."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "Let {plugin} forward mail to {addresses}?",
|
||||
"message": "{plugin} wants to forward “{subject}” with its attachments to {addresses}. If you allow it, it can forward mail to this address from now on without asking. You can take this back in Settings > Add-ons.",
|
||||
"allow": "Allow and forward"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "From the plugin {name}",
|
||||
"ok": "OK",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "Cuentas conectadas",
|
||||
"account_on": "{label} en {host}",
|
||||
"account_waiting": "{label} en {host} (inicio de sesión sin terminar)",
|
||||
"forward_heading": "Reenvía correo a",
|
||||
"forward_hint": "Direcciones a las que este plugin puede reenviar mensajes sin preguntar.",
|
||||
"forward_remove": "Quitar",
|
||||
"forward_remove_label": "Dejar de reenviar a {address}",
|
||||
"disconnect": "Desconectar",
|
||||
"disconnect_failed": "No se pudo desconectar {label}"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "Cambiar el mensaje que escribes",
|
||||
"body": "Sustituir o añadir texto en un mensaje mientras lo escribes, cuando usas el complemento en el editor. Puedes deshacer cada cambio y sigues decidiendo si lo envías."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "Reenviar tu correo",
|
||||
"body": "Reenviar mensajes de tu cuenta con sus adjuntos, sin abrir el editor. Se te pregunta antes del primer reenvío a cada dirección."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Mostrar notificaciones",
|
||||
"body": "Mostrar notificaciones en tu dispositivo, o dentro de la aplicación si no las has permitido. Cada una indica el nombre del complemento."
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "Enviar solicitudes a través del servidor de {appName} con una cuenta o clave configurada por ti o por tu administrador. El complemento nunca ve la contraseña, pero puede hacer todo lo que esa cuenta puede hacer en su propio servidor."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "¿Permitir que {plugin} reenvíe correo a {addresses}?",
|
||||
"message": "{plugin} quiere reenviar «{subject}» con sus adjuntos a {addresses}. Si lo permites, podrá reenviar correo a esta dirección a partir de ahora sin preguntar. Puedes revocarlo en Configuración > Complementos.",
|
||||
"allow": "Permitir y reenviar"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "Del complemento {name}",
|
||||
"ok": "Aceptar",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "Ühendatud kontod",
|
||||
"account_on": "{label} serveris {host}",
|
||||
"account_waiting": "{label} serveris {host} (sisselogimine pole lõpetatud)",
|
||||
"forward_heading": "Saadab kirju edasi aadressile",
|
||||
"forward_hint": "Aadressid, kuhu see pistikprogramm võib kirju küsimata edasi saata.",
|
||||
"forward_remove": "Eemalda",
|
||||
"forward_remove_label": "Lõpeta edasisaatmine aadressile {address}",
|
||||
"disconnect": "Katkesta ühendus",
|
||||
"disconnect_failed": "Ühenduse katkestamine ebaõnnestus: {label}"
|
||||
},
|
||||
@@ -5692,11 +5696,20 @@
|
||||
"title": "Muuda kirja, mida kirjutad",
|
||||
"body": "Asendada või lisada teksti kirjas selle kirjutamise ajal, kui kasutad pistikprogrammi kirjakoostajas. Iga muudatuse saad tagasi võtta ja sina otsustad ikka, kas saata."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "Sinu kirjade edasisaatmine",
|
||||
"body": "Saadab sinu konto kirju koos manustega edasi ilma koostajat avamata. Enne esimest edasisaatmist igale aadressile küsitakse sinult luba."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Näita märguandeid",
|
||||
"body": "Näidata märguandeid sinu seadmes või rakenduses, kui pole neid lubanud. Iga märguanne nimetab pistikprogrammi."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "Kas lubada pistikprogrammil {plugin} saata kirju edasi aadressile {addresses}?",
|
||||
"message": "{plugin} soovib saata kirja „{subject}“ koos manustega edasi aadressile {addresses}. Kui lubad, saab see edaspidi sellele aadressile kirju küsimata edasi saata. Saad selle tagasi võtta jaotises Seaded > Lisad.",
|
||||
"allow": "Luba ja saada edasi"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "Pistikprogrammilt {name}",
|
||||
"ok": "OK",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "حسابهای متصل",
|
||||
"account_on": "{label} روی {host}",
|
||||
"account_waiting": "{label} روی {host} (ورود کامل نشده)",
|
||||
"forward_heading": "ایمیل را هدایت میکند به",
|
||||
"forward_hint": "نشانیهایی که این افزونه میتواند بدون پرسیدن پیامها را به آنها هدایت کند.",
|
||||
"forward_remove": "حذف",
|
||||
"forward_remove_label": "توقف هدایت به {address}",
|
||||
"disconnect": "قطع اتصال",
|
||||
"disconnect_failed": "قطع اتصال {label} ممکن نشد"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "تغییر پیامی که مینویسید",
|
||||
"body": "جایگزینی یا افزودن متن در پیام هنگام نوشتن آن، وقتی افزونه را در ویرایشگر به کار میبرید. هر تغییر را میتوانید برگردانید و تصمیم ارسال همچنان با شماست."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "هدایت ایمیلهای شما",
|
||||
"body": "پیامهای حساب شما را همراه با پیوستها، بدون باز کردن ویرایشگر، هدایت میکند. پیش از اولین هدایت به هر نشانی از شما پرسیده میشود."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "نمایش اعلانها",
|
||||
"body": "نمایش اعلان روی دستگاه شما، یا داخل برنامه اگر اجازه اعلان ندادهاید. هر اعلان نام افزونه را نشان میدهد."
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "ارسال درخواست از طریق سرور {appName} با حساب یا کلیدی که شما یا مدیرتان راهاندازی کردهاید. افزونه هرگز گذرواژه را نمیبیند، اما میتواند هر کاری را که آن حساب روی سرور خودش میتواند انجام دهد، انجام دهد."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "به {plugin} اجازه داده شود ایمیل را به {addresses} هدایت کند؟",
|
||||
"message": "{plugin} میخواهد «{subject}» را همراه با پیوستها به {addresses} هدایت کند. اگر اجازه دهید، از این پس میتواند بدون پرسیدن، ایمیل را به این نشانی هدایت کند. میتوانید این اجازه را در تنظیمات > افزونهها پس بگیرید.",
|
||||
"allow": "اجازه و هدایت"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "از افزونه {name}",
|
||||
"ok": "تأیید",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "Comptes connectés",
|
||||
"account_on": "{label} sur {host}",
|
||||
"account_waiting": "{label} sur {host} (connexion non terminée)",
|
||||
"forward_heading": "Transfère les e-mails à",
|
||||
"forward_hint": "Adresses auxquelles ce plugin peut transférer des messages sans demander.",
|
||||
"forward_remove": "Retirer",
|
||||
"forward_remove_label": "Ne plus transférer à {address}",
|
||||
"disconnect": "Déconnecter",
|
||||
"disconnect_failed": "Impossible de déconnecter {label}"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "Modifier le message que vous écrivez",
|
||||
"body": "Remplacer ou ajouter du texte dans un message pendant que vous l'écrivez, quand vous utilisez le plugin dans l'éditeur. Chaque modification peut être annulée, et c'est toujours vous qui décidez de l'envoyer."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "Transférer vos e-mails",
|
||||
"body": "Transférer des messages de votre compte avec leurs pièces jointes, sans ouvrir l'éditeur. Vous êtes consulté avant le premier transfert vers chaque adresse."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Afficher des notifications",
|
||||
"body": "Afficher des notifications sur votre appareil, ou dans l'application si vous ne les avez pas autorisées. Chacune indique le nom du plugin."
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "Envoyer des requêtes via le serveur de {appName} avec un compte ou une clé configurés par vous ou votre administrateur. Le plugin ne voit jamais le mot de passe, mais il peut faire tout ce que ce compte peut faire sur son propre serveur."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "Autoriser {plugin} à transférer des e-mails à {addresses} ?",
|
||||
"message": "{plugin} veut transférer « {subject} » avec ses pièces jointes à {addresses}. Si vous l'autorisez, il pourra désormais transférer des e-mails à cette adresse sans vous demander. Vous pouvez revenir sur ce choix dans Paramètres > Extensions.",
|
||||
"allow": "Autoriser et transférer"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "Du plugin {name}",
|
||||
"ok": "OK",
|
||||
|
||||
@@ -5423,6 +5423,10 @@
|
||||
"accounts_heading": "חשבונות מחוברים",
|
||||
"account_on": "{label} בשרת {host}",
|
||||
"account_waiting": "{label} בשרת {host} (ההתחברות לא הושלמה)",
|
||||
"forward_heading": "מעביר דואר אל",
|
||||
"forward_hint": "כתובות שהתוסף הזה רשאי להעביר אליהן הודעות בלי לשאול.",
|
||||
"forward_remove": "הסרה",
|
||||
"forward_remove_label": "הפסקת ההעברה אל {address}",
|
||||
"disconnect": "ניתוק",
|
||||
"disconnect_failed": "לא ניתן לנתק את {label}"
|
||||
},
|
||||
@@ -5665,6 +5669,10 @@
|
||||
"title": "שינוי ההודעה שאתם כותבים",
|
||||
"body": "החלפה או הוספה של טקסט בהודעה בזמן הכתיבה, כשמשתמשים בתוסף בעורך. אפשר לבטל כל שינוי, וההחלטה אם לשלוח נשארת שלכם."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "העברת הדואר שלכם",
|
||||
"body": "העברת הודעות מהחשבון שלכם יחד עם הקבצים המצורפים, בלי לפתוח את חלון הכתיבה. לפני ההעברה הראשונה לכל כתובת תתבקשו לאשר."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "הצגת התראות",
|
||||
"body": "הצגת התראות במכשיר שלכם, או בתוך האפליקציה אם לא אישרתם התראות. כל התראה מציינת את שם התוסף."
|
||||
@@ -5698,6 +5706,11 @@
|
||||
"body": "שליחת בקשות דרך השרת של {appName} עם חשבון או מפתח שהוגדרו על ידיכם או על ידי מנהל המערכת. התוסף אף פעם לא רואה את הסיסמה, אבל הוא יכול לעשות כל מה שהחשבון הזה יכול לעשות בשרת שלו."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "לאפשר ל-{plugin} להעביר דואר אל {addresses}?",
|
||||
"message": "{plugin} רוצה להעביר את „{subject}“ יחד עם הקבצים המצורפים אל {addresses}. אם תאשרו, מעכשיו הוא יוכל להעביר דואר לכתובת זו בלי לשאול. אפשר לבטל זאת בהגדרות > תוספים.",
|
||||
"allow": "לאשר ולהעביר"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "מהתוסף {name}",
|
||||
"ok": "אישור",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "Csatlakoztatott fiókok",
|
||||
"account_on": "{label} – {host}",
|
||||
"account_waiting": "{label} – {host} (a bejelentkezés nincs befejezve)",
|
||||
"forward_heading": "Leveleket továbbít ide",
|
||||
"forward_hint": "Címek, amelyekre ez a bővítmény kérdés nélkül továbbíthat üzeneteket.",
|
||||
"forward_remove": "Eltávolítás",
|
||||
"forward_remove_label": "Továbbítás leállítása ide: {address}",
|
||||
"disconnect": "Leválasztás",
|
||||
"disconnect_failed": "Nem sikerült leválasztani: {label}"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "Az éppen írt üzeneted módosítása",
|
||||
"body": "Szöveg cseréje vagy hozzáadása az üzenetben írás közben, amikor a bővítményt a szerkesztőben használod. Minden módosítás visszavonható, és továbbra is te döntöd el, elküldöd-e."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "Leveleid továbbítása",
|
||||
"body": "Üzeneteket továbbít a fiókodból a mellékleteikkel együtt, a szerkesztő megnyitása nélkül. Minden címre az első továbbítás előtt megkérdez."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Értesítések megjelenítése",
|
||||
"body": "Értesítéseket jelenít meg az eszközödön, vagy az alkalmazásban, ha nem engedélyezted őket. Mindegyiken szerepel a bővítmény neve."
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "Kéréseket küld a(z) {appName} szerverén keresztül egy olyan fiókkal vagy kulccsal, amelyet te vagy a rendszergazdád állított be. A bővítmény soha nem látja a jelszót, de bármit megtehet, amit az a fiók a saját szerverén megtehet."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "Engedélyezed, hogy a(z) {plugin} leveleket továbbítson ide: {addresses}?",
|
||||
"message": "A(z) {plugin} továbbítaná a(z) „{subject}” üzenetet a mellékleteivel együtt ide: {addresses}. Ha engedélyezed, mostantól kérdés nélkül továbbíthat leveleket erre a címre. Ezt a Beállítások > Bővítmények alatt vonhatod vissza.",
|
||||
"allow": "Engedélyezés és továbbítás"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "A(z) {name} bővítménytől",
|
||||
"ok": "OK",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "Account collegati",
|
||||
"account_on": "{label} su {host}",
|
||||
"account_waiting": "{label} su {host} (accesso non completato)",
|
||||
"forward_heading": "Inoltra la posta a",
|
||||
"forward_hint": "Indirizzi a cui questo plugin può inoltrare messaggi senza chiedere.",
|
||||
"forward_remove": "Rimuovi",
|
||||
"forward_remove_label": "Interrompi l'inoltro a {address}",
|
||||
"disconnect": "Disconnetti",
|
||||
"disconnect_failed": "Impossibile disconnettere {label}"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "Modificare il messaggio che stai scrivendo",
|
||||
"body": "Sostituire o aggiungere testo in un messaggio mentre lo scrivi, quando usi il plugin nell'editor. Puoi annullare ogni modifica e decidi sempre tu se inviarlo."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "Inoltrare la tua posta",
|
||||
"body": "Inoltrare messaggi dal tuo account con i loro allegati, senza aprire l'editor. Ti viene chiesto prima del primo inoltro a ciascun indirizzo."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Mostrare notifiche",
|
||||
"body": "Mostrare notifiche sul tuo dispositivo, o nell'app se non le hai consentite. Ognuna indica il nome del plugin."
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "Inviare richieste tramite il server di {appName} con un account o una chiave configurati da te o dal tuo amministratore. Il plugin non vede mai la password, ma può fare tutto ciò che quell'account può fare sul proprio server."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "Consentire a {plugin} di inoltrare posta a {addresses}?",
|
||||
"message": "{plugin} vuole inoltrare «{subject}» con i suoi allegati a {addresses}. Se lo consenti, d'ora in poi potrà inoltrare posta a questo indirizzo senza chiedere. Puoi revocarlo in Impostazioni > Componenti aggiuntivi.",
|
||||
"allow": "Consenti e inoltra"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "Dal plugin {name}",
|
||||
"ok": "OK",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "接続済みのアカウント",
|
||||
"account_on": "{host} の {label}",
|
||||
"account_waiting": "{host} の {label} (サインイン未完了)",
|
||||
"forward_heading": "メールの転送先",
|
||||
"forward_hint": "このプラグインが確認なしでメッセージを転送できるアドレスです。",
|
||||
"forward_remove": "削除",
|
||||
"forward_remove_label": "{address} への転送を停止",
|
||||
"disconnect": "接続を解除",
|
||||
"disconnect_failed": "{label} の接続を解除できませんでした"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "作成中のメッセージを変更する",
|
||||
"body": "エディターでプラグインを使ったときに、作成中のメッセージのテキストを置き換えたり追加したりします。変更はひとつずつ元に戻せ、送信するかどうかは引き続きあなたが決めます。"
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "メールを転送",
|
||||
"body": "作成画面を開かずに、アカウント内のメッセージを添付ファイルごと転送します。各アドレスへの最初の転送の前に確認されます。"
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "通知を表示",
|
||||
"body": "デバイスに通知を表示します。通知を許可していない場合はアプリ内に表示します。どの通知にもプラグイン名が入ります。"
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "あなたまたは管理者が設定したアカウントやキーを使って、{appName} のサーバー経由でリクエストを送信します。プラグインがパスワードを見ることはありませんが、そのアカウントがそのサーバーでできることはすべて実行できます。"
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "{plugin} に {addresses} へのメール転送を許可しますか?",
|
||||
"message": "{plugin} が「{subject}」を添付ファイルごと {addresses} に転送しようとしています。許可すると、今後はこのアドレスへ確認なしでメールを転送できます。設定 > アドオン でいつでも取り消せます。",
|
||||
"allow": "許可して転送"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "プラグイン {name} から",
|
||||
"ok": "OK",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "연결된 계정",
|
||||
"account_on": "{host}의 {label}",
|
||||
"account_waiting": "{host}의 {label} (로그인 미완료)",
|
||||
"forward_heading": "메일 전달 대상",
|
||||
"forward_hint": "이 플러그인이 묻지 않고 메시지를 전달할 수 있는 주소예요.",
|
||||
"forward_remove": "삭제",
|
||||
"forward_remove_label": "{address}(으)로 전달 중지",
|
||||
"disconnect": "연결 해제",
|
||||
"disconnect_failed": "{label} 연결을 해제하지 못했어요"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "작성 중인 메시지 변경",
|
||||
"body": "편집기에서 플러그인을 사용할 때 작성 중인 메시지의 텍스트를 바꾸거나 추가해요. 각 변경은 되돌릴 수 있고, 보낼지는 여전히 직접 결정해요."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "메일 전달",
|
||||
"body": "작성 창을 열지 않고 계정의 메시지를 첨부 파일과 함께 전달해요. 주소마다 처음 전달하기 전에 물어봐요."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "알림 표시",
|
||||
"body": "기기에 알림을 표시하고, 알림을 허용하지 않았다면 앱 안에 표시해요. 모든 알림에 플러그인 이름이 나와요."
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "나 또는 관리자가 설정한 계정이나 키로 {appName} 서버를 거쳐 요청을 보낼 수 있어요. 플러그인은 비밀번호를 볼 수 없지만, 해당 계정이 그 서버에서 할 수 있는 모든 작업을 할 수 있어요."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "{plugin}이(가) {addresses}(으)로 메일을 전달하도록 허용할까요?",
|
||||
"message": "{plugin}이(가) ‘{subject}’을(를) 첨부 파일과 함께 {addresses}(으)로 전달하려고 해요. 허용하면 앞으로 이 주소로는 묻지 않고 메일을 전달할 수 있어요. 설정 > 추가 기능에서 언제든지 취소할 수 있어요.",
|
||||
"allow": "허용하고 전달"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "플러그인 {name}에서",
|
||||
"ok": "확인",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "Savienotie konti",
|
||||
"account_on": "{label} serverī {host}",
|
||||
"account_waiting": "{label} serverī {host} (pierakstīšanās nav pabeigta)",
|
||||
"forward_heading": "Pārsūta pastu uz",
|
||||
"forward_hint": "Adreses, uz kurām šis spraudnis drīkst pārsūtīt vēstules, nejautājot.",
|
||||
"forward_remove": "Noņemt",
|
||||
"forward_remove_label": "Pārtraukt pārsūtīšanu uz {address}",
|
||||
"disconnect": "Atvienot",
|
||||
"disconnect_failed": "Neizdevās atvienot {label}"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "Mainīt ziņojumu, ko rakstāt",
|
||||
"body": "Aizstāt vai pievienot tekstu ziņojumā rakstīšanas laikā, kad izmantojat spraudni redaktorā. Katru izmaiņu var atsaukt, un jūs joprojām izlemjat, vai sūtīt."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "Pārsūtīt jūsu pastu",
|
||||
"body": "Pārsūtīt vēstules no jūsu konta kopā ar pielikumiem, neatverot redaktoru. Pirms pirmās pārsūtīšanas uz katru adresi jums tiek jautāts."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Rādīt paziņojumus",
|
||||
"body": "Rādīt paziņojumus jūsu ierīcē vai lietotnē, ja neesat tos atļāvis. Katrā ir spraudņa nosaukums."
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "Sūtīt pieprasījumus caur {appName} serveri ar kontu vai atslēgu, ko iestatījāt jūs vai jūsu administrators. Spraudnis nekad neredz paroli, taču tas var darīt visu, ko šis konts var darīt savā serverī."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "Atļaut spraudnim {plugin} pārsūtīt pastu uz {addresses}?",
|
||||
"message": "{plugin} vēlas pārsūtīt “{subject}” kopā ar pielikumiem uz {addresses}. Ja jūs to atļausiet, turpmāk tas varēs pārsūtīt pastu uz šo adresi, nejautājot. To var atsaukt sadaļā Iestatījumi > Papildinājumi.",
|
||||
"allow": "Atļaut un pārsūtīt"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "No spraudņa {name}",
|
||||
"ok": "Labi",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "Холбогдсон бүртгэлүүд",
|
||||
"account_on": "{host} дээрх {label}",
|
||||
"account_waiting": "{host} дээрх {label} (нэвтрэлт дуусаагүй)",
|
||||
"forward_heading": "Имэйлийг дамжуулдаг хаяг",
|
||||
"forward_hint": "Энэ нэмэлт асуулгүйгээр имэйл дамжуулж болох хаягууд.",
|
||||
"forward_remove": "Хасах",
|
||||
"forward_remove_label": "{address} руу дамжуулахыг зогсоох",
|
||||
"disconnect": "Салгах",
|
||||
"disconnect_failed": "{label} салгаж чадсангүй"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "Бичиж буй захидлаа өөрчлөх",
|
||||
"body": "Засварлагч дээр плагиныг ашиглах үед бичиж буй захидлын текстийг солих эсвэл нэмэх. Өөрчлөлт бүрийг буцааж болох бөгөөд илгээх эсэхийг та өөрөө шийднэ."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "Таны имэйлийг дамжуулах",
|
||||
"body": "Таны бүртгэлийн имэйлүүдийг хавсралттай нь, бичих цонх нээлгүйгээр дамжуулна. Хаяг бүрт анх дамжуулахын өмнө танаас асууна."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Мэдэгдэл харуулах",
|
||||
"body": "Таны төхөөрөмж дээр мэдэгдэл харуулах, эсвэл мэдэгдлийг зөвшөөрөөгүй бол аппын дотор харуулах. Мэдэгдэл бүр залгаасын нэрийг заана."
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "Таны эсвэл администраторын тохируулсан бүртгэл эсвэл түлхүүрээр {appName}-ийн серверээр дамжуулан хүсэлт илгээх. Залгаас нууц үгийг хэзээ ч харахгүй, гэхдээ тухайн бүртгэлийн өөрийн сервер дээр хийж чадах бүх зүйлийг хийж чадна."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "{plugin}-д {addresses} руу имэйл дамжуулахыг зөвшөөрөх үү?",
|
||||
"message": "{plugin} «{subject}»-г хавсралттай нь {addresses} руу дамжуулахыг хүсэж байна. Зөвшөөрвөл цаашид энэ хаяг руу асуулгүйгээр имэйл дамжуулж чадна. Үүнийг Тохиргоо > Нэмэлтүүд хэсгээс цуцалж болно.",
|
||||
"allow": "Зөвшөөрч дамжуулах"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "{name} залгаасаас",
|
||||
"ok": "OK",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "Tilkoblede kontoer",
|
||||
"account_on": "{label} på {host}",
|
||||
"account_waiting": "{label} på {host} (pålogging ikke fullført)",
|
||||
"forward_heading": "Videresender e-post til",
|
||||
"forward_hint": "Adresser dette programtillegget kan videresende meldinger til uten å spørre.",
|
||||
"forward_remove": "Fjern",
|
||||
"forward_remove_label": "Slutt å videresende til {address}",
|
||||
"disconnect": "Koble fra",
|
||||
"disconnect_failed": "Kunne ikke koble fra {label}"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "Endre meldingen du skriver",
|
||||
"body": "Erstatte eller legge til tekst i en melding mens du skriver den, når du bruker tillegget i redigeringsvinduet. Du kan angre hver endring, og du bestemmer fortsatt om den skal sendes."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "Videresende e-posten din",
|
||||
"body": "Videresende meldinger fra kontoen din med vedlegg, uten å åpne redigeringsvinduet. Du blir spurt før første videresending til hver adresse."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Vise varsler",
|
||||
"body": "Vise varsler på enheten din, eller i appen hvis du ikke har tillatt dem. Hvert varsel viser navnet på tillegget."
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "Sende forespørsler via {appName}s server med en konto eller nøkkel som du eller administratoren din har satt opp. Tillegget ser aldri passordet, men det kan gjøre alt den kontoen kan gjøre på sin egen server."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "Tillate {plugin} å videresende e-post til {addresses}?",
|
||||
"message": "{plugin} vil videresende «{subject}» med vedlegg til {addresses}. Hvis du tillater det, kan den heretter videresende e-post til denne adressen uten å spørre. Du kan trekke det tilbake under Innstillinger > Tillegg.",
|
||||
"allow": "Tillat og videresend"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "Fra tillegget {name}",
|
||||
"ok": "OK",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "Gekoppelde accounts",
|
||||
"account_on": "{label} op {host}",
|
||||
"account_waiting": "{label} op {host} (inloggen niet afgerond)",
|
||||
"forward_heading": "Stuurt e-mail door naar",
|
||||
"forward_hint": "Adressen waarnaar deze plugin berichten mag doorsturen zonder te vragen.",
|
||||
"forward_remove": "Verwijderen",
|
||||
"forward_remove_label": "Niet meer doorsturen naar {address}",
|
||||
"disconnect": "Ontkoppelen",
|
||||
"disconnect_failed": "Kan {label} niet ontkoppelen"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "Het bericht wijzigen dat je schrijft",
|
||||
"body": "Tekst in een bericht vervangen of toevoegen terwijl je het schrijft, als je de plug-in in de editor gebruikt. Je kunt elke wijziging ongedaan maken en je beslist nog steeds zelf of je het verstuurt."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "Je e-mail doorsturen",
|
||||
"body": "Berichten uit je account met hun bijlagen doorsturen, zonder de editor te openen. Voor de eerste keer doorsturen naar een adres wordt het je gevraagd."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Meldingen tonen",
|
||||
"body": "Meldingen op je apparaat tonen, of in de app als je ze niet hebt toegestaan. Elke melding noemt de naam van de plug-in."
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "Verzoeken via de server van {appName} sturen met een account of sleutel die jij of je beheerder heeft ingesteld. De plug-in ziet het wachtwoord nooit, maar kan alles doen wat dat account op zijn eigen server mag."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "{plugin} toestaan e-mail door te sturen naar {addresses}?",
|
||||
"message": "{plugin} wil ‘{subject}’ met de bijlagen doorsturen naar {addresses}. Als je dit toestaat, kan het voortaan zonder te vragen e-mail naar dit adres doorsturen. Je kunt dit intrekken via Instellingen > Add-ons.",
|
||||
"allow": "Toestaan en doorsturen"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "Van de plug-in {name}",
|
||||
"ok": "OK",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "Połączone konta",
|
||||
"account_on": "{label} na {host}",
|
||||
"account_waiting": "{label} na {host} (logowanie niedokończone)",
|
||||
"forward_heading": "Przekazuje pocztę na",
|
||||
"forward_hint": "Adresy, na które ta wtyczka może przekazywać wiadomości bez pytania.",
|
||||
"forward_remove": "Usuń",
|
||||
"forward_remove_label": "Przestań przekazywać na {address}",
|
||||
"disconnect": "Rozłącz",
|
||||
"disconnect_failed": "Nie udało się rozłączyć {label}"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "Zmiana pisanej wiadomości",
|
||||
"body": "Zastępowanie lub dodawanie tekstu w wiadomości podczas pisania, gdy używasz wtyczki w edytorze. Każdą zmianę możesz cofnąć, a o wysłaniu nadal decydujesz Ty."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "Przekazywanie Twojej poczty",
|
||||
"body": "Przekazuje wiadomości z Twojego konta razem z załącznikami, bez otwierania edytora. Przed pierwszym przekazaniem na każdy adres zostaniesz zapytany."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Wyświetlanie powiadomień",
|
||||
"body": "Wyświetla powiadomienia na urządzeniu lub w aplikacji, jeśli ich nie zezwolono. Każde podaje nazwę wtyczki."
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "Wysyła żądania przez serwer {appName} z użyciem konta lub klucza skonfigurowanego przez Ciebie lub Twojego administratora. Wtyczka nigdy nie widzi hasła, ale może zrobić na tym serwerze wszystko, co może to konto."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "Pozwolić wtyczce {plugin} przekazywać pocztę na {addresses}?",
|
||||
"message": "{plugin} chce przekazać „{subject}” razem z załącznikami na {addresses}. Jeśli na to pozwolisz, będzie od teraz mogła przekazywać pocztę na ten adres bez pytania. Możesz to cofnąć w Ustawienia > Dodatki.",
|
||||
"allow": "Zezwól i przekaż"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "Od wtyczki {name}",
|
||||
"ok": "OK",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "Contas conectadas",
|
||||
"account_on": "{label} em {host}",
|
||||
"account_waiting": "{label} em {host} (login não concluído)",
|
||||
"forward_heading": "Encaminha e-mails para",
|
||||
"forward_hint": "Endereços para os quais este plugin pode encaminhar mensagens sem perguntar.",
|
||||
"forward_remove": "Remover",
|
||||
"forward_remove_label": "Parar de encaminhar para {address}",
|
||||
"disconnect": "Desconectar",
|
||||
"disconnect_failed": "Não foi possível desconectar {label}"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "Alterar a mensagem que você está escrevendo",
|
||||
"body": "Substituir ou adicionar texto em uma mensagem enquanto você a escreve, quando usa o plugin no editor. Você pode desfazer cada alteração e continua decidindo se envia."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "Encaminhar seus e-mails",
|
||||
"body": "Encaminhar mensagens da sua conta com os anexos, sem abrir o editor. Você é consultado antes do primeiro encaminhamento para cada endereço."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Mostrar notificações",
|
||||
"body": "Mostrar notificações no seu dispositivo, ou dentro do aplicativo se você não as permitiu. Cada uma indica o nome do plugin."
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "Enviar solicitações pelo servidor do {appName} com uma conta ou chave configurada por você ou pelo seu administrador. O plugin nunca vê a senha, mas pode fazer tudo o que essa conta pode fazer no próprio servidor."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "Permitir que {plugin} encaminhe e-mails para {addresses}?",
|
||||
"message": "{plugin} quer encaminhar “{subject}” com os anexos para {addresses}. Se você permitir, ele poderá encaminhar e-mails para esse endereço daqui em diante sem perguntar. Você pode desfazer isso em Configurações > Extras.",
|
||||
"allow": "Permitir e encaminhar"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "Do plugin {name}",
|
||||
"ok": "OK",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "Conturi conectate",
|
||||
"account_on": "{label} pe {host}",
|
||||
"account_waiting": "{label} pe {host} (conectare neterminată)",
|
||||
"forward_heading": "Redirecționează mesaje către",
|
||||
"forward_hint": "Adrese către care acest plugin poate redirecționa mesaje fără să întrebe.",
|
||||
"forward_remove": "Eliminați",
|
||||
"forward_remove_label": "Opriți redirecționarea către {address}",
|
||||
"disconnect": "Deconectați",
|
||||
"disconnect_failed": "{label} nu a putut fi deconectat"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "Modificarea mesajului pe care îl scrieți",
|
||||
"body": "Înlocuirea sau adăugarea de text într-un mesaj în timp ce îl scrieți, când folosiți pluginul în editor. Puteți anula fiecare modificare și decideți în continuare dacă îl trimiteți."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "Redirecționarea mesajelor dvs.",
|
||||
"body": "Redirecționează mesaje din contul dvs. împreună cu atașamentele, fără a deschide editorul. Sunteți întrebat înainte de prima redirecționare către fiecare adresă."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Afișarea notificărilor",
|
||||
"body": "Afișează notificări pe dispozitiv sau în aplicație, dacă nu le-ați permis. Fiecare indică numele pluginului."
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "Trimite cereri prin serverul {appName} cu un cont sau o cheie configurate de dvs. sau de administratorul dvs. Pluginul nu vede niciodată parola, dar poate face tot ce poate face acel cont pe serverul său."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "Permiteți ca {plugin} să redirecționeze mesaje către {addresses}?",
|
||||
"message": "{plugin} vrea să redirecționeze „{subject}” împreună cu atașamentele către {addresses}. Dacă permiteți, de acum înainte va putea redirecționa mesaje către această adresă fără să întrebe. Puteți retrage permisiunea din Setări > Suplimente.",
|
||||
"allow": "Permiteți și redirecționați"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "De la pluginul {name}",
|
||||
"ok": "OK",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "Подключённые аккаунты",
|
||||
"account_on": "{label} на {host}",
|
||||
"account_waiting": "{label} на {host} (вход не завершён)",
|
||||
"forward_heading": "Пересылает почту на",
|
||||
"forward_hint": "Адреса, на которые этот плагин может пересылать письма без вопросов.",
|
||||
"forward_remove": "Удалить",
|
||||
"forward_remove_label": "Прекратить пересылку на {address}",
|
||||
"disconnect": "Отключить",
|
||||
"disconnect_failed": "Не удалось отключить {label}"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "Изменять письмо, которое вы пишете",
|
||||
"body": "Заменять или добавлять текст в письме, пока вы его пишете, когда вы используете плагин в редакторе. Каждое изменение можно отменить, а отправлять ли письмо, решаете вы."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "Пересылать вашу почту",
|
||||
"body": "Пересылать письма из вашего аккаунта вместе с вложениями, не открывая редактор. Перед первой пересылкой на каждый адрес у вас спросят разрешение."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Показывать уведомления",
|
||||
"body": "Показывать уведомления на вашем устройстве или в приложении, если вы их не разрешили. В каждом указано название плагина."
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "Отправлять запросы через сервер {appName} с аккаунтом или ключом, который настроили вы или ваш администратор. Плагин никогда не видит пароль, но может делать на этом сервере всё, что может этот аккаунт."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "Разрешить {plugin} пересылать почту на {addresses}?",
|
||||
"message": "{plugin} хочет переслать «{subject}» вместе с вложениями на {addresses}. Если вы разрешите, он сможет и дальше пересылать почту на этот адрес без вопросов. Отозвать разрешение можно в разделе Настройки > Дополнения.",
|
||||
"allow": "Разрешить и переслать"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "От плагина {name}",
|
||||
"ok": "ОК",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "Pripojené účty",
|
||||
"account_on": "{label} na {host}",
|
||||
"account_waiting": "{label} na {host} (prihlásenie nedokončené)",
|
||||
"forward_heading": "Preposiela poštu na",
|
||||
"forward_hint": "Adresy, na ktoré smie tento doplnok preposielať správy bez pýtania.",
|
||||
"forward_remove": "Odstrániť",
|
||||
"forward_remove_label": "Prestať preposielať na {address}",
|
||||
"disconnect": "Odpojiť",
|
||||
"disconnect_failed": "{label} sa nepodarilo odpojiť"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "Meniť správu, ktorú píšete",
|
||||
"body": "Nahrádzať alebo pridávať text v správe počas písania, keď doplnok použijete v editore. Každú zmenu môžete vrátiť a o odoslaní stále rozhodujete vy."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "Preposielať vašu poštu",
|
||||
"body": "Preposiela správy z vášho účtu aj s prílohami bez otvorenia editora. Pred prvým preposlaním na každú adresu sa vás opýta."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Zobrazovať upozornenia",
|
||||
"body": "Zobrazuje upozornenia vo vašom zariadení alebo v aplikácii, ak ste ich nepovolili. Každé uvádza názov doplnku."
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "Odosiela požiadavky cez server aplikácie {appName} pomocou účtu alebo kľúča, ktorý ste nastavili vy alebo váš správca. Doplnok nikdy nevidí heslo, ale môže na príslušnom serveri robiť čokoľvek, čo môže tento účet."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "Povoliť doplnku {plugin} preposielať poštu na {addresses}?",
|
||||
"message": "{plugin} chce preposlať „{subject}“ aj s prílohami na {addresses}. Ak to povolíte, bude odteraz môcť preposielať poštu na túto adresu bez pýtania. Odvolať to môžete v Nastaveniach > Doplnky.",
|
||||
"allow": "Povoliť a preposlať"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "Od doplnku {name}",
|
||||
"ok": "OK",
|
||||
|
||||
@@ -2380,6 +2380,10 @@
|
||||
"accounts_heading": "บัญชีที่เชื่อมต่อ",
|
||||
"account_on": "{label} บน {host}",
|
||||
"account_waiting": "{label} บน {host} (ยังลงชื่อเข้าใช้ไม่เสร็จสมบูรณ์)",
|
||||
"forward_heading": "ส่งต่ออีเมลไปยัง",
|
||||
"forward_hint": "ที่อยู่ที่ปลั๊กอินนี้ส่งต่อข้อความไปได้โดยไม่ต้องถาม",
|
||||
"forward_remove": "นำออก",
|
||||
"forward_remove_label": "หยุดส่งต่อไปยัง {address}",
|
||||
"disconnect": "ยกเลิกการเชื่อมต่อ",
|
||||
"disconnect_failed": "ไม่สามารถยกเลิกการเชื่อมต่อ {label} ได้"
|
||||
},
|
||||
@@ -2650,11 +2654,20 @@
|
||||
"title": "ปรับแต่งข้อความที่คุณกำลังเขียน",
|
||||
"body": "แทนที่หรือเพิ่มข้อความในขณะที่คุณเขียน เมื่อคุณใช้ปลั๊กอินในหน้าต่างเขียนอีเมล คุณสามารถเลิกทำการเปลี่ยนแปลงแต่ละครั้งได้ และยังคงเป็นผู้ตัดสินใจว่าจะส่งข้อความหรือไม่"
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "ส่งต่ออีเมลของคุณ",
|
||||
"body": "ส่งต่อข้อความจากบัญชีของคุณพร้อมไฟล์แนบ โดยไม่ต้องเปิดหน้าเขียนข้อความ ระบบจะถามคุณก่อนส่งต่อไปยังแต่ละที่อยู่เป็นครั้งแรก"
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "แสดงการแจ้งเตือน",
|
||||
"body": "แสดงการแจ้งเตือนบนอุปกรณ์ของคุณ หรือภายในแอปหากคุณยังไม่ได้อนุญาตการแจ้งเตือนของอุปกรณ์ โดยแต่ละการแจ้งเตือนจะระบุชื่อปลั๊กอิน"
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "อนุญาตให้ {plugin} ส่งต่ออีเมลไปยัง {addresses} หรือไม่",
|
||||
"message": "{plugin} ต้องการส่งต่อ “{subject}” พร้อมไฟล์แนบไปยัง {addresses} หากคุณอนุญาต จากนี้ไปปลั๊กอินจะส่งต่ออีเมลไปยังที่อยู่นี้ได้โดยไม่ต้องถาม คุณยกเลิกได้ที่ การตั้งค่า > ส่วนเสริม",
|
||||
"allow": "อนุญาตและส่งต่อ"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "จากปลั๊กอิน {name}",
|
||||
"ok": "ตกลง",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "Bağlı hesaplar",
|
||||
"account_on": "{host} üzerinde {label}",
|
||||
"account_waiting": "{host} üzerinde {label} (giriş tamamlanmadı)",
|
||||
"forward_heading": "Postaları şuraya iletir",
|
||||
"forward_hint": "Bu eklentinin sormadan ileti iletebileceği adresler.",
|
||||
"forward_remove": "Kaldır",
|
||||
"forward_remove_label": "{address} adresine iletmeyi durdur",
|
||||
"disconnect": "Bağlantıyı kes",
|
||||
"disconnect_failed": "{label} bağlantısı kesilemedi"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "Yazdığınız iletiyi değiştirme",
|
||||
"body": "Eklentiyi düzenleyicide kullandığınızda, yazarken iletide metni değiştirir veya ekler. Her değişikliği geri alabilirsiniz ve göndermeye yine siz karar verirsiniz."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "Postanızı iletme",
|
||||
"body": "Hesabınızdaki iletileri ekleriyle birlikte, düzenleyiciyi açmadan iletir. Her adrese ilk iletmeden önce size sorulur."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Bildirim gösterme",
|
||||
"body": "Cihazınızda bildirim gösterme ya da bildirimlere izin vermediyseniz uygulama içinde gösterme. Her bildirimde eklentinin adı yer alır."
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "Sizin veya yöneticinizin ayarladığı bir hesap ya da anahtarla {appName} sunucusu üzerinden istek gönderme. Eklenti parolayı hiçbir zaman görmez, ancak o hesabın kendi sunucusunda yapabildiği her şeyi yapabilir."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "{plugin} eklentisinin {addresses} adresine posta iletmesine izin verilsin mi?",
|
||||
"message": "{plugin}, “{subject}” iletisini ekleriyle birlikte {addresses} adresine iletmek istiyor. İzin verirseniz bundan sonra bu adrese sormadan posta iletebilir. Bunu Ayarlar > Eklentiler bölümünden geri alabilirsiniz.",
|
||||
"allow": "İzin ver ve ilet"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "{name} eklentisinden",
|
||||
"ok": "Tamam",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "Підключені облікові записи",
|
||||
"account_on": "{label} на {host}",
|
||||
"account_waiting": "{label} на {host} (вхід не завершено)",
|
||||
"forward_heading": "Пересилає пошту на",
|
||||
"forward_hint": "Адреси, на які цей плагін може пересилати листи без запитань.",
|
||||
"forward_remove": "Видалити",
|
||||
"forward_remove_label": "Припинити пересилання на {address}",
|
||||
"disconnect": "Відключити",
|
||||
"disconnect_failed": "Не вдалося відключити {label}"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "Змінювати лист, який ви пишете",
|
||||
"body": "Замінювати або додавати текст у листі, поки ви його пишете, коли ви користуєтеся плагіном у редакторі. Кожну зміну можна скасувати, а надсилати чи ні, вирішуєте ви."
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "Пересилати вашу пошту",
|
||||
"body": "Пересилати листи з вашого облікового запису разом із вкладеннями, не відкриваючи редактор. Перед першим пересиланням на кожну адресу у вас запитають дозвіл."
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "Показувати сповіщення",
|
||||
"body": "Показувати сповіщення на вашому пристрої або в програмі, якщо ви їх не дозволили. У кожному вказано назву плагіна."
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "Надсилати запити через сервер {appName} з обліковим записом або ключем, який налаштували ви або ваш адміністратор. Плагін ніколи не бачить пароль, але може робити на цьому сервері все, що може цей обліковий запис."
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "Дозволити {plugin} пересилати пошту на {addresses}?",
|
||||
"message": "{plugin} хоче переслати «{subject}» разом із вкладеннями на {addresses}. Якщо ви дозволите, він зможе й надалі пересилати пошту на цю адресу без запитань. Відкликати дозвіл можна в розділі Налаштування > Додатки.",
|
||||
"allow": "Дозволити й переслати"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "Від плагіна {name}",
|
||||
"ok": "OK",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "已連線的帳號",
|
||||
"account_on": "{host} 上的 {label}",
|
||||
"account_waiting": "{host} 上的 {label}(尚未完成登入)",
|
||||
"forward_heading": "郵件轉寄到",
|
||||
"forward_hint": "這個外掛程式可以不經詢問轉寄郵件的地址。",
|
||||
"forward_remove": "移除",
|
||||
"forward_remove_label": "停止轉寄到 {address}",
|
||||
"disconnect": "中斷連線",
|
||||
"disconnect_failed": "無法中斷 {label} 的連線"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "修改您正在撰寫的郵件",
|
||||
"body": "在編輯器中使用外掛程式時,取代或新增您正在撰寫的郵件中的文字。每次修改都可以復原,是否寄出仍由您決定。"
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "轉寄您的郵件",
|
||||
"body": "不必開啟撰寫視窗,即可將您帳號中的郵件連同附件一起轉寄。第一次轉寄到每個地址前都會先徵求您的同意。"
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "顯示通知",
|
||||
"body": "在您的裝置上顯示通知;如果您未允許通知,則在應用程式內顯示。每則通知都會註明外掛程式名稱。"
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "使用您或管理員設定的帳號或金鑰,透過 {appName} 的伺服器傳送要求。外掛程式永遠看不到密碼,但可以在該帳號所屬的伺服器上執行該帳號能做的任何動作。"
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "允許 {plugin} 將郵件轉寄到 {addresses} 嗎?",
|
||||
"message": "{plugin} 想將「{subject}」連同附件轉寄到 {addresses}。如果允許,它之後可以不經詢問將郵件轉寄到這個地址。您可以在 設定 > 附加元件 中撤銷。",
|
||||
"allow": "允許並轉寄"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "來自外掛程式 {name}",
|
||||
"ok": "確定",
|
||||
|
||||
@@ -5422,6 +5422,10 @@
|
||||
"accounts_heading": "已连接的账户",
|
||||
"account_on": "{host} 上的 {label}",
|
||||
"account_waiting": "{host} 上的 {label}(尚未完成登录)",
|
||||
"forward_heading": "邮件转发到",
|
||||
"forward_hint": "此插件可以不经询问转发邮件的地址。",
|
||||
"forward_remove": "移除",
|
||||
"forward_remove_label": "停止转发到 {address}",
|
||||
"disconnect": "断开连接",
|
||||
"disconnect_failed": "无法断开 {label} 的连接"
|
||||
},
|
||||
@@ -5664,6 +5668,10 @@
|
||||
"title": "修改您正在写的邮件",
|
||||
"body": "在编辑器中使用插件时,替换或添加您正在写的邮件中的文字。每次修改都可以撤销,是否发送仍由您决定。"
|
||||
},
|
||||
"email_forward": {
|
||||
"title": "转发您的邮件",
|
||||
"body": "无需打开撰写窗口,即可将您账户中的邮件连同附件一起转发。首次转发到每个地址前都会征求您的同意。"
|
||||
},
|
||||
"ui_notifications": {
|
||||
"title": "显示通知",
|
||||
"body": "在您的设备上显示通知;如果您未允许通知,则在应用内显示。每条通知都会注明插件名称。"
|
||||
@@ -5697,6 +5705,11 @@
|
||||
"body": "使用您或管理员设置的账户或密钥,通过 {appName} 的服务器发送请求。插件永远看不到密码,但可以在该账户所属的服务器上执行该账户能做的任何操作。"
|
||||
}
|
||||
},
|
||||
"forward_approval": {
|
||||
"title": "允许 {plugin} 将邮件转发到 {addresses} 吗?",
|
||||
"message": "{plugin} 想将“{subject}”连同附件转发到 {addresses}。如果允许,它今后可以不经询问将邮件转发到该地址。您可以在 设置 > 附加组件 中撤销。",
|
||||
"allow": "允许并转发"
|
||||
},
|
||||
"dialog": {
|
||||
"from_plugin": "来自插件 {name}",
|
||||
"ok": "确定",
|
||||
|
||||
Reference in New Issue
Block a user