mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: let plugins open a filled-in composer and save drafts
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mailtoComposerDraft } from "../mailto-draft";
|
||||
|
||||
const base = { to: ["bob@example.com"], cc: [], bcc: ["x@example.com"], subject: "Hi", body: "Line 1\nLine 2" };
|
||||
|
||||
describe("mailtoComposerDraft", () => {
|
||||
it("keeps a mailto: request as before: plain body, format from the setting", () => {
|
||||
const plain = mailtoComposerDraft(base, true);
|
||||
expect(plain).toEqual(expect.objectContaining({
|
||||
to: "bob@example.com", bcc: "x@example.com", showBcc: true, subject: "Hi", body: "Line 1\nLine 2", mode: "compose",
|
||||
}));
|
||||
expect(plain).not.toHaveProperty("plainTextMode");
|
||||
expect(plain).not.toHaveProperty("attachments");
|
||||
expect(mailtoComposerDraft(base, false).body).not.toBe(base.body);
|
||||
});
|
||||
|
||||
it("opens a plugin's HTML body in HTML mode, even with plain text on", () => {
|
||||
const draft = mailtoComposerDraft({ ...base, htmlBody: "<p>Hello <b>you</b></p>" }, true);
|
||||
expect(draft.body).toBe("<p>Hello <b>you</b></p>");
|
||||
expect(draft.plainTextMode).toBe(false);
|
||||
});
|
||||
|
||||
it("carries a plugin's uploaded attachments", () => {
|
||||
const attachments = [{ blobId: "b1", name: "a.pdf", type: "application/pdf", size: 10 }];
|
||||
expect(mailtoComposerDraft({ ...base, attachments }, false).attachments).toEqual(attachments);
|
||||
});
|
||||
});
|
||||
@@ -114,7 +114,8 @@ import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
|
||||
import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { plainTextToComposerBody, getQuoteBodies } from "@/lib/email-composer-utils";
|
||||
import { mailtoComposerDraft } from "./mailto-draft";
|
||||
import { getQuoteBodies } from "@/lib/email-composer-utils";
|
||||
import { emailHooks } from "@/lib/plugin-hooks";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import { buildQuoteHeader } from "@/lib/quote-header";
|
||||
@@ -1102,27 +1103,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||
|
||||
const applyMailtoDraft = useCallback((pending: ParsedMailto, suppressOutgoingStash: boolean) => {
|
||||
const body = useSettingsStore.getState().plainTextMode
|
||||
? pending.body
|
||||
: plainTextToComposerBody(pending.body);
|
||||
|
||||
if (suppressOutgoingStash) {
|
||||
suppressComposerStateSaveSessionRef.current = composerSessionId;
|
||||
}
|
||||
setComposerSessionId((id) => id + 1);
|
||||
setPendingDraft({
|
||||
to: pending.to.join(", "),
|
||||
cc: pending.cc.join(", "),
|
||||
bcc: pending.bcc.join(", "),
|
||||
subject: pending.subject,
|
||||
body,
|
||||
showCc: pending.cc.length > 0,
|
||||
showBcc: pending.bcc.length > 0,
|
||||
selectedIdentityId: null,
|
||||
subAddressTag: "",
|
||||
mode: "compose",
|
||||
draftId: null,
|
||||
});
|
||||
setPendingDraft(mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode));
|
||||
setComposerMode("compose");
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView("viewer");
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ComposerDraftData } from "@/components/email/email-composer";
|
||||
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { plainTextToComposerBody } from "@/lib/email-composer-utils";
|
||||
|
||||
/**
|
||||
* The composer draft for an in-app mailto: request or a plugin's
|
||||
* api.compose.open. A plain body follows the "plain text only" setting; an
|
||||
* HTML body (only a plugin sends one, sanitised by the host) opens in HTML
|
||||
* mode whatever that setting says. Attachments a plugin uploaded come along
|
||||
* as blob references, like those of a re-opened draft.
|
||||
*/
|
||||
export function mailtoComposerDraft(pending: ParsedMailto, plainTextMode: boolean): ComposerDraftData {
|
||||
const isHtml = typeof pending.htmlBody === "string";
|
||||
const body = isHtml
|
||||
? pending.htmlBody!
|
||||
: plainTextMode
|
||||
? pending.body
|
||||
: plainTextToComposerBody(pending.body);
|
||||
return {
|
||||
to: pending.to.join(", "),
|
||||
cc: pending.cc.join(", "),
|
||||
bcc: pending.bcc.join(", "),
|
||||
subject: pending.subject,
|
||||
body,
|
||||
showCc: pending.cc.length > 0,
|
||||
showBcc: pending.bcc.length > 0,
|
||||
selectedIdentityId: null,
|
||||
subAddressTag: "",
|
||||
mode: "compose",
|
||||
draftId: null,
|
||||
...(isHtml ? { plainTextMode: false } : {}),
|
||||
...(pending.attachments?.length ? { attachments: pending.attachments } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { InstalledPlugin } from '../plugin-types';
|
||||
import type { IJMAPClient } from '../jmap/client-interface';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from '../protocol-handlers/mailto';
|
||||
import { consumePendingMailto } from '../protocol-handlers/session';
|
||||
import { resetPluginRateLimits } from '../plugin-sandbox/host-api-compose';
|
||||
import { HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text';
|
||||
import { ALL_PERMISSIONS } from '../plugin-types';
|
||||
|
||||
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 = 'cloud'): InstalledPlugin {
|
||||
return {
|
||||
id, name: 'Cloud', version: '1.0.0', author: 'test', description: '',
|
||||
type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
|
||||
enabled: true, status: 'running', settings: {},
|
||||
};
|
||||
}
|
||||
|
||||
const COMPOSER = plugin(['compose:open']);
|
||||
const DRAFTER = plugin(['email:write']);
|
||||
const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args);
|
||||
const pdf = { blobId: 'b1', name: 'report.pdf', type: 'application/pdf', size: 1200 };
|
||||
|
||||
describe('api.compose.open', () => {
|
||||
let taken: ParsedMailto[];
|
||||
const listener = (event: Event) => {
|
||||
event.preventDefault();
|
||||
taken.push((event as CustomEvent<ParsedMailto>).detail);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
taken = [];
|
||||
resetPluginRateLimits();
|
||||
window.addEventListener(INTERNAL_MAILTO_EVENT, listener);
|
||||
});
|
||||
afterEach(() => {
|
||||
window.removeEventListener(INTERNAL_MAILTO_EVENT, listener);
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('hands the mail page a filled-in draft, like an in-app mailto: link', async () => {
|
||||
expect(await call(COMPOSER, 'compose.open', {
|
||||
to: ['Bob <bob@example.com>'], cc: ['carol@example.com'], subject: 'Report', body: 'See attached', attachments: [pdf],
|
||||
})).toBe('opened');
|
||||
expect(taken).toEqual([{
|
||||
to: ['Bob <bob@example.com>'], cc: ['carol@example.com'], bcc: [], subject: 'Report', body: 'See attached', attachments: [pdf],
|
||||
}]);
|
||||
});
|
||||
|
||||
it('sanitises an HTML body and sends it as htmlBody', async () => {
|
||||
await call(COMPOSER, 'compose.open', { body: '<p>Hi <b>there</b></p><img src=x onerror="steal()">', isHtml: true });
|
||||
expect(taken[0].htmlBody).toContain('<b>there</b>');
|
||||
expect(taken[0].htmlBody).not.toContain('onerror');
|
||||
expect(taken[0].body).toMatch(/Hi there/);
|
||||
});
|
||||
|
||||
it('parks the draft for the mail page when none is on screen', async () => {
|
||||
window.removeEventListener(INTERNAL_MAILTO_EVENT, listener);
|
||||
// jsdom does not navigate; the call still reports where it is going.
|
||||
expect(await call(COMPOSER, 'compose.open', { to: ['bob@example.com'], subject: 'Hi' })).toBe('navigating');
|
||||
expect(consumePendingMailto()).toEqual(expect.objectContaining({ to: ['bob@example.com'], subject: 'Hi' }));
|
||||
});
|
||||
|
||||
it('refuses bad addresses, header injection, too many recipients and odd attachments', async () => {
|
||||
await expect(call(COMPOSER, 'compose.open', { to: ['nobody'] })).rejects.toThrow(/to\[0\] is not a valid address/);
|
||||
await expect(call(COMPOSER, 'compose.open', { subject: 'Hi\r\nBcc: x@example.com' })).rejects.toThrow(/subject must be one line/);
|
||||
const many = Array.from({ length: 101 }, (_, i) => `u${i}@example.com`);
|
||||
await expect(call(COMPOSER, 'compose.open', { to: many })).rejects.toThrow(/at most 100 addresses/);
|
||||
await expect(call(COMPOSER, 'compose.open', { to: many.slice(0, 60), cc: many.slice(60) })).rejects.toThrow(/at most 100 recipients in all/);
|
||||
await expect(call(COMPOSER, 'compose.open', { attachments: [{ ...pdf, type: 'not a type' }] })).rejects.toThrow(/not a media type/);
|
||||
await expect(call(COMPOSER, 'compose.open', { from: 'boss@example.com' })).rejects.toThrow(/unknown property "from"/);
|
||||
expect(taken).toEqual([]);
|
||||
});
|
||||
|
||||
it('opens at most five times a minute per plugin', async () => {
|
||||
for (let i = 0; i < 5; i++) await call(COMPOSER, 'compose.open', {});
|
||||
await expect(call(COMPOSER, 'compose.open', {})).rejects.toThrow(/at most 5 times a minute/);
|
||||
expect(await call(plugin(['compose:open'], 'other'), 'compose.open', {})).toBe('opened');
|
||||
expect(taken).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('needs compose:open, a new permission that is not marked high risk', async () => {
|
||||
await expect(call(plugin(['email:send']), 'compose.open', {})).rejects.toThrow(/lacks permission "compose:open"/);
|
||||
expect(taken).toEqual([]);
|
||||
expect(ALL_PERMISSIONS).toContain('compose:open');
|
||||
expect(HIGH_RISK_PERMISSIONS.has('compose:open')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('api.drafts.create', () => {
|
||||
let createDraft: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
resetPluginRateLimits();
|
||||
createDraft = vi.fn(async () => 'draft-1');
|
||||
useAuthStore.setState({ client: { createDraft } as unknown as IJMAPClient });
|
||||
useIdentityStore.setState({
|
||||
identities: [
|
||||
{ id: 'id-a', name: 'Ann', email: 'ann@example.com' },
|
||||
{ id: 'id-b', name: 'Ann Work', email: 'ann@work.example' },
|
||||
] as never,
|
||||
preferredPrimaryId: 'id-b',
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
useAuthStore.setState({ client: null });
|
||||
useIdentityStore.setState({ identities: [], preferredPrimaryId: null });
|
||||
});
|
||||
|
||||
it('saves a draft from the default identity and returns its id', async () => {
|
||||
expect(await call(DRAFTER, 'drafts.create', {
|
||||
to: ['bob@example.com'], subject: 'Notes', body: '<p>Hello</p><script>x()</script>', isHtml: true, attachments: [pdf],
|
||||
})).toBe('draft-1');
|
||||
const [to, subject, text, cc, bcc, identityId, fromEmail, draftId, attachments, fromName, html] = createDraft.mock.calls[0];
|
||||
expect({ to, subject, cc, bcc, identityId, fromEmail, draftId, fromName }).toEqual({
|
||||
to: ['bob@example.com'], subject: 'Notes', cc: [], bcc: [], identityId: 'id-b',
|
||||
fromEmail: 'ann@work.example', draftId: undefined, fromName: 'Ann Work',
|
||||
});
|
||||
expect(text).toMatch(/Hello/);
|
||||
expect(html).toContain('<p>Hello</p>');
|
||||
expect(html).not.toContain('script');
|
||||
expect(attachments).toEqual([{ ...pdf, disposition: 'attachment' }]);
|
||||
});
|
||||
|
||||
it('needs email:write and valid input', async () => {
|
||||
await expect(call(plugin(['compose:open']), 'drafts.create', {})).rejects.toThrow(/lacks permission "email:write"/);
|
||||
await expect(call(DRAFTER, 'drafts.create', { to: 'bob@example.com' })).rejects.toThrow(/to must be an array/);
|
||||
await expect(call(DRAFTER, 'drafts.create', { attachments: [{ blobId: 'b1' }] })).rejects.toThrow(/attachments\[0\]\.type must be a string/);
|
||||
expect(createDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves at most ten drafts a minute', async () => {
|
||||
for (let i = 0; i < 10; i++) await call(DRAFTER, 'drafts.create', {});
|
||||
await expect(call(DRAFTER, 'drafts.create', {})).rejects.toThrow(/at most 10 drafts a minute/);
|
||||
expect(createDraft).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
// Host side of api.compose.open and api.drafts.create. compose.open fills
|
||||
// the composer the way an in-app mailto: link does, and the user still has
|
||||
// to press Send; drafts.create saves a draft to the Drafts folder without
|
||||
// opening anything. Neither sends mail.
|
||||
|
||||
import type { ComposeInput, DraftInput, InstalledPlugin } from '../plugin-types';
|
||||
import type { ParsedMailto } from '../protocol-handlers/mailto';
|
||||
import { requestInternalCompose } from '../protocol-handlers/mailto';
|
||||
import { notifyPendingMailto, savePendingMailto } from '../protocol-handlers/session';
|
||||
import { getPathPrefix } from '../browser-navigation';
|
||||
import { locales } from '@/i18n/routing';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { sanitizePluginBodyHtml } from '../email-sanitization';
|
||||
import { htmlToPlainText } from '../html-to-text';
|
||||
import {
|
||||
readAddressList, readBlobRefs, readBoolean, readObject, readString, sessionClient, type PluginBlobRef,
|
||||
} from './host-api-input';
|
||||
|
||||
/** Most recipients (to, cc and bcc together) one call may set. */
|
||||
export const MAX_PLUGIN_RECIPIENTS = 100;
|
||||
/** Most attachments one call may add. */
|
||||
export const MAX_PLUGIN_ATTACHMENTS = 20;
|
||||
const MAX_SUBJECT = 998;
|
||||
const MAX_BODY = 512 * 1024;
|
||||
// A blob a plugin uploaded can be as big as the server lets it be; this
|
||||
// only keeps the number sane.
|
||||
const MAX_ATTACHMENT_SIZE = 2 ** 31;
|
||||
|
||||
interface MessageFields {
|
||||
to: string[];
|
||||
cc: string[];
|
||||
bcc: string[];
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string | undefined;
|
||||
attachments: PluginBlobRef[];
|
||||
}
|
||||
|
||||
function readMessage(method: string, data: Record<string, unknown>): MessageFields {
|
||||
const to = readAddressList(data.to, method, 'to', MAX_PLUGIN_RECIPIENTS);
|
||||
const cc = readAddressList(data.cc, method, 'cc', MAX_PLUGIN_RECIPIENTS);
|
||||
const bcc = readAddressList(data.bcc, method, 'bcc', MAX_PLUGIN_RECIPIENTS);
|
||||
if (to.length + cc.length + bcc.length > MAX_PLUGIN_RECIPIENTS) {
|
||||
throw new Error(`${method}: at most ${MAX_PLUGIN_RECIPIENTS} recipients in all`);
|
||||
}
|
||||
const subject = data.subject === undefined ? '' : readString(data.subject, method, 'subject', MAX_SUBJECT);
|
||||
// eslint-disable-next-line no-control-regex -- a subject is one header line
|
||||
if (/[\u0000-\u001f\u007f]/.test(subject)) throw new Error(`${method}: subject must be one line`);
|
||||
const body = data.body === undefined ? '' : readString(data.body, method, 'body', MAX_BODY);
|
||||
const isHtml = data.isHtml === undefined ? false : readBoolean(data.isHtml, method, 'isHtml');
|
||||
const html = isHtml ? sanitizePluginBodyHtml(body) : undefined;
|
||||
return {
|
||||
to, cc, bcc, subject,
|
||||
text: html !== undefined ? htmlToPlainText(html) : body,
|
||||
html,
|
||||
attachments: readBlobRefs(data.attachments, method, MAX_PLUGIN_ATTACHMENTS, MAX_ATTACHMENT_SIZE),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Rate limits ──────────────────────────────────────────────
|
||||
|
||||
const recentCalls = new Map<string, number[]>();
|
||||
|
||||
/** True when `pluginId` made fewer than `max` `kind` calls in the last minute; records this one. */
|
||||
export function withinRate(kind: string, pluginId: string, max: number, windowMs = 60_000): boolean {
|
||||
const key = `${kind}\u0000${pluginId}`;
|
||||
const now = Date.now();
|
||||
const recent = (recentCalls.get(key) ?? []).filter((t) => now - t < windowMs);
|
||||
if (recent.length >= max) {
|
||||
recentCalls.set(key, recent);
|
||||
return false;
|
||||
}
|
||||
recent.push(now);
|
||||
recentCalls.set(key, recent);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Forget the rate-limit history (tests). */
|
||||
export function resetPluginRateLimits(): void {
|
||||
recentCalls.clear();
|
||||
}
|
||||
|
||||
// ─── compose.open ─────────────────────────────────────────────
|
||||
|
||||
const COMPOSE_PER_MINUTE = 5;
|
||||
|
||||
/** The mail page in the current mount and locale. */
|
||||
function mailPageHref(): string {
|
||||
const segments = window.location.pathname.split('/').filter(Boolean);
|
||||
const localeIndex = segments.findIndex((s) => (locales as readonly string[]).includes(s));
|
||||
if (localeIndex >= 0) return '/' + segments.slice(0, localeIndex + 1).join('/') + '/';
|
||||
return getPathPrefix() + '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the composer filled in. On the mail page it opens in place (asking
|
||||
* first when a draft is already open); elsewhere the app goes to the mail
|
||||
* page, which opens it. Resolves to 'opened' or 'navigating'.
|
||||
*/
|
||||
export function doComposeOpen(plugin: InstalledPlugin, input: unknown): 'opened' | 'navigating' {
|
||||
const method = 'compose.open';
|
||||
const data = readObject(input, method, 'message', ['to', 'cc', 'bcc', 'subject', 'body', 'isHtml', 'attachments'], true) as ComposeInput & Record<string, unknown>;
|
||||
const message = readMessage(method, data);
|
||||
if (!withinRate('compose', plugin.id, COMPOSE_PER_MINUTE)) {
|
||||
throw new Error(`${method}: at most ${COMPOSE_PER_MINUTE} times a minute`);
|
||||
}
|
||||
const draft: ParsedMailto = {
|
||||
to: message.to,
|
||||
cc: message.cc,
|
||||
bcc: message.bcc,
|
||||
subject: message.subject,
|
||||
body: message.text,
|
||||
...(message.html !== undefined ? { htmlBody: message.html } : {}),
|
||||
...(message.attachments.length ? { attachments: message.attachments } : {}),
|
||||
};
|
||||
if (requestInternalCompose(draft)) return 'opened';
|
||||
// No mail page on screen: park the draft where the mail page looks for an
|
||||
// incoming mailto: request when it mounts, and go there.
|
||||
savePendingMailto(draft);
|
||||
notifyPendingMailto();
|
||||
window.location.assign(mailPageHref());
|
||||
return 'navigating';
|
||||
}
|
||||
|
||||
// ─── drafts.create ────────────────────────────────────────────
|
||||
|
||||
const DRAFTS_PER_MINUTE = 10;
|
||||
|
||||
/** Save a draft in the Drafts folder, from the user's default identity. Resolves to its id. */
|
||||
export async function doDraftsCreate(plugin: InstalledPlugin, input: unknown): Promise<string> {
|
||||
const method = 'drafts.create';
|
||||
const data = readObject(input, method, 'draft', ['to', 'cc', 'bcc', 'subject', 'body', 'isHtml', 'attachments'], true) as DraftInput & Record<string, unknown>;
|
||||
const message = readMessage(method, data);
|
||||
const client = sessionClient(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 save the draft from`);
|
||||
if (!withinRate('drafts', plugin.id, DRAFTS_PER_MINUTE)) {
|
||||
throw new Error(`${method}: at most ${DRAFTS_PER_MINUTE} drafts a minute`);
|
||||
}
|
||||
return client.createDraft(
|
||||
message.to,
|
||||
message.subject,
|
||||
message.text,
|
||||
message.cc,
|
||||
message.bcc,
|
||||
identity.id,
|
||||
identity.email,
|
||||
undefined,
|
||||
message.attachments.map((a) => ({ ...a, disposition: 'attachment' as const })),
|
||||
identity.name || undefined,
|
||||
message.html,
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
doEmailGet, doEmailGetAttachment, doEmailGetRaw, doEmailList, doMailboxesList,
|
||||
} from './host-api-mail';
|
||||
import { MAX_PLUGIN_FETCH_BYTES } from './host-api-input';
|
||||
import { doComposeOpen, doDraftsCreate } from './host-api-compose';
|
||||
|
||||
/**
|
||||
* Methods only callable from the privileged (same-origin) tier. These expose
|
||||
@@ -222,6 +223,10 @@ export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
|
||||
// plugin names, so the untrusted tier may call them.
|
||||
'email.getAttachment': 'email:blob-read',
|
||||
'email.getRaw': 'email:blob-read',
|
||||
// Open the composer filled in; the user still decides whether to send.
|
||||
'compose.open': 'compose:open',
|
||||
// Save a draft without opening the composer. Nothing is sent.
|
||||
'drafts.create': 'email:write',
|
||||
};
|
||||
|
||||
// ─── Cross-origin allow-list (mirrors lib/plugin-api.ts) ──────
|
||||
@@ -1912,6 +1917,9 @@ export async function dispatchApiCall(
|
||||
case 'email.getAttachment': return doEmailGetAttachment(args[0], args[1]);
|
||||
case 'email.getRaw': return doEmailGetRaw(args[0]);
|
||||
|
||||
case 'compose.open': return doComposeOpen(plugin, args[0]);
|
||||
case 'drafts.create': return doDraftsCreate(plugin, args[0]);
|
||||
|
||||
default:
|
||||
throw new Error(`Unhandled method "${method}"`);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme';
|
||||
import { interpolate, lookup, textDirection, type LocaleTables } from './plugin-i18n';
|
||||
import { isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout';
|
||||
import type {
|
||||
CalendarEventInput, CalendarEventView, CalendarView, EmailReadView, EmailSummary, IdentityPatch,
|
||||
CalendarEventInput, CalendarEventView, CalendarView, ComposeInput, DraftInput, EmailReadView, EmailSummary, IdentityPatch,
|
||||
IdentityView, MailboxView, SlotName, TaskInput, TaskView, TemplateInput, TemplateView, VacationInput,
|
||||
VacationView,
|
||||
} from '../plugin-types';
|
||||
@@ -459,6 +459,27 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
removeKeyword: (emailId: string, keyword: string, accountId?: string) =>
|
||||
callApi('email.removeKeyword', [emailId, keyword, accountId]) as Promise<void>,
|
||||
},
|
||||
// The composer (permission: compose:open). Opens it filled in; the user
|
||||
// reviews the message and decides whether to send it.
|
||||
compose: {
|
||||
/**
|
||||
* Open the composer with these fields. On the mail page it opens in
|
||||
* place; anywhere else the app first goes to the mail page (the
|
||||
* plugin is reloaded with it). Resolves to 'opened' or 'navigating'.
|
||||
* At most 5 times a minute.
|
||||
*/
|
||||
open: (message: ComposeInput) => callApi('compose.open', [message]) as Promise<'opened' | 'navigating'>,
|
||||
},
|
||||
// Drafts (permission: email:write).
|
||||
drafts: {
|
||||
/**
|
||||
* Save a draft in the Drafts folder, from the user's default sender
|
||||
* address, without opening the composer. Attachments are blobs the
|
||||
* plugin uploaded with api.jmap.uploadBlob. Resolves to the draft's
|
||||
* email id. At most 10 a minute.
|
||||
*/
|
||||
create: (draft: DraftInput) => callApi('drafts.create', [draft]) as Promise<string>,
|
||||
},
|
||||
// Folders (permission: email:read).
|
||||
mailboxes: {
|
||||
/** Every folder with its role, parent and message counts. */
|
||||
|
||||
@@ -686,6 +686,40 @@ export interface VacationInput {
|
||||
toDate?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A file the plugin uploaded with `api.jmap.uploadBlob`, to attach to a
|
||||
* message it opens or saves.
|
||||
*/
|
||||
export interface BlobAttachmentRef {
|
||||
blobId: string;
|
||||
/** 1-255 characters. */
|
||||
name: string;
|
||||
/** A media type such as application/pdf. */
|
||||
type: string;
|
||||
/** Bytes. */
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a plugin passes to `api.compose.open`. Addresses are
|
||||
* `a@example.com` or `Name <a@example.com>`; at most 100 recipients and 20
|
||||
* attachments.
|
||||
*/
|
||||
export interface ComposeInput {
|
||||
to?: string[];
|
||||
cc?: string[];
|
||||
bcc?: string[];
|
||||
/** One line, up to 998 characters. */
|
||||
subject?: string;
|
||||
/** Up to 512 KB. Plain text unless isHtml; HTML is sanitised. */
|
||||
body?: string;
|
||||
isHtml?: boolean;
|
||||
attachments?: BlobAttachmentRef[];
|
||||
}
|
||||
|
||||
/** What a plugin passes to `api.drafts.create`: the same fields as ComposeInput. */
|
||||
export type DraftInput = ComposeInput;
|
||||
|
||||
export interface FilterRuleView {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -1199,6 +1233,9 @@ export const ALL_PERMISSIONS = [
|
||||
'email:blob-write',
|
||||
// Replace the rendered body of an opened email (render-takeover).
|
||||
'email:render-takeover',
|
||||
// Open the composer filled in (api.compose.open). Low risk: the user still
|
||||
// has to press Send.
|
||||
'compose:open',
|
||||
'calendar:read', 'calendar:write',
|
||||
'contacts:read', 'contacts:write',
|
||||
'files:read', 'files:write',
|
||||
|
||||
@@ -6,6 +6,14 @@ export interface ParsedMailto {
|
||||
bcc: string[];
|
||||
subject: string;
|
||||
body: string;
|
||||
/**
|
||||
* Only from a plugin's api.compose.open, never from a mailto: URL: an HTML
|
||||
* body (sanitised by the host) the composer opens in HTML mode instead of
|
||||
* `body`.
|
||||
*/
|
||||
htmlBody?: string;
|
||||
/** Only from api.compose.open: files the plugin uploaded, attached as they are. */
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>;
|
||||
}
|
||||
|
||||
const MAX_RECIPIENTS = 200;
|
||||
@@ -134,10 +142,19 @@ export function requestInternalMailto(raw: string): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
const parsed = parseMailto(raw);
|
||||
if (!parsed || parsed.to.length === 0) return false;
|
||||
return requestInternalCompose(parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the mail page to open its composer with this draft, the way an in-app
|
||||
* mailto: click does. Returns false when no mail page is mounted to take it.
|
||||
*/
|
||||
export function requestInternalCompose(draft: ParsedMailto): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
// The listener signals "handled" by cancelling the event, which makes
|
||||
// dispatchEvent report false - so an uncancelled dispatch means nobody took it.
|
||||
const uncancelled = window.dispatchEvent(
|
||||
new CustomEvent<ParsedMailto>(INTERNAL_MAILTO_EVENT, { detail: parsed, cancelable: true }),
|
||||
new CustomEvent<ParsedMailto>(INTERNAL_MAILTO_EVENT, { detail: draft, cancelable: true }),
|
||||
);
|
||||
return !uncancelled;
|
||||
}
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "تغيير الوسوم والعلامات على بريدك",
|
||||
"body": "إضافة الوسوم والعلامات والكلمات المفتاحية الأخرى إلى رسائلك أو إزالتها."
|
||||
"body": "إضافة الوسوم والعلامات والكلمات المفتاحية الأخرى إلى رسائلك أو إزالتها، وحفظ المسودات."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "تغيير البريد قبل إرساله",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "معرفة وقت بدء التطبيق وتوقفه",
|
||||
"body": "تلقّي إشعار عند بدء التطبيق أو انقطاع اتصاله أو تسجيل خروجك."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "فتح محرر الرسائل لك",
|
||||
"body": "فتح رسالة جديدة بعد تعبئة المستلمين أو النص أو المرفقات. يبقى قرار إرسالها لك."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "Canviar les etiquetes i marques del correu",
|
||||
"body": "Afegir o treure etiquetes, marques i altres paraules clau dels vostres missatges."
|
||||
"body": "Afegir o treure etiquetes, marques i altres paraules clau dels vostres missatges, i desar esborranys."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "Canviar el correu abans d'enviar-lo",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "Saber quan l'aplicació s'inicia i s'atura",
|
||||
"body": "Rebre un avís quan l'aplicació s'inicia, es queda sense connexió o tanqueu la sessió."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "Obrir el redactor per a vós",
|
||||
"body": "Obrir un missatge nou amb els destinataris, el text o els adjunts ja emplenats. Sou vós qui decidiu si s'envia."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "Měnit štítky a příznaky pošty",
|
||||
"body": "Přidává nebo odebírá štítky, příznaky a další klíčová slova u vašich zpráv."
|
||||
"body": "Přidává nebo odebírá štítky, příznaky a další klíčová slova u vašich zpráv a ukládá koncepty."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "Měnit poštu před odesláním",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "Vědět, kdy se aplikace spouští a ukončuje",
|
||||
"body": "Dostává zprávu, když se aplikace spustí, přejde do režimu offline nebo když se odhlásíte."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "Otevírat editor zprávy",
|
||||
"body": "Otevře novou zprávu s vyplněnými příjemci, textem nebo přílohami. O odeslání stále rozhodujete vy."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "Ændre tags og markeringer på dine e-mails",
|
||||
"body": "Tilføje eller fjerne tags, markeringer og andre nøgleord på dine beskeder."
|
||||
"body": "Tilføje eller fjerne tags, markeringer og andre nøgleord på dine beskeder og gemme kladder."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "Ændre e-mails, før de sendes",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "Vide, når appen starter og stopper",
|
||||
"body": "Få besked, når appen starter, går offline, eller du logger ud."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "Åbne komponisten for dig",
|
||||
"body": "Åbne en ny besked med modtagere, tekst eller vedhæftninger udfyldt. Du bestemmer stadig, om den sendes."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "Labels und Markierungen Ihrer E-Mails ändern",
|
||||
"body": "Labels, Markierungen und andere Schlüsselwörter an Ihren Nachrichten hinzufügen oder entfernen."
|
||||
"body": "Labels, Markierungen und andere Schlüsselwörter an Ihren Nachrichten hinzufügen oder entfernen und Entwürfe speichern."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "E-Mails vor dem Senden ändern",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "Erfahren, wann die App startet und beendet wird",
|
||||
"body": "Benachrichtigt werden, wenn die App startet, offline geht oder Sie sich abmelden."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "Den Editor für Sie öffnen",
|
||||
"body": "Eine neue Nachricht mit bereits ausgefüllten Empfängern, Text oder Anhängen öffnen. Ob sie gesendet wird, entscheiden weiterhin Sie."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "Change tags and flags on your email",
|
||||
"body": "Add or remove tags, flags and other keywords on your messages."
|
||||
"body": "Add or remove tags, flags and other keywords on your messages, and save drafts."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "Change mail before it is sent",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "Know when the app starts and stops",
|
||||
"body": "Be told when the app starts, goes offline or you sign out."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "Open the composer for you",
|
||||
"body": "Open a new message with recipients, text or attachments filled in. You still decide whether to send it."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "Cambiar etiquetas y marcas de tu correo",
|
||||
"body": "Añadir o quitar etiquetas, marcas y otras palabras clave de tus mensajes."
|
||||
"body": "Añadir o quitar etiquetas, marcas y otras palabras clave de tus mensajes, y guardar borradores."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "Cambiar el correo antes de enviarlo",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "Saber cuándo se inicia y se cierra la aplicación",
|
||||
"body": "Recibir un aviso cuando la aplicación se inicia, se queda sin conexión o cierras sesión."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "Abrir el editor por ti",
|
||||
"body": "Abrir un mensaje nuevo con los destinatarios, el texto o los adjuntos ya rellenados. Tú sigues decidiendo si se envía."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "تغییر برچسبها و پرچمهای ایمیلهای شما",
|
||||
"body": "افزودن یا حذف برچسبها، پرچمها و کلیدواژههای دیگر روی پیامهای شما."
|
||||
"body": "افزودن یا حذف برچسبها، پرچمها و کلیدواژههای دیگر روی پیامهای شما، و ذخیره پیشنویسها."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "تغییر ایمیل پیش از ارسال",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "باخبر شدن از شروع و توقف برنامه",
|
||||
"body": "باخبر شدن وقتی برنامه شروع میشود، آفلاین میشود یا از حساب خارج میشوید."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "باز کردن ویرایشگر برای شما",
|
||||
"body": "باز کردن پیامی تازه با گیرندگان، متن یا پیوستهای ازپیشپرشده. تصمیم ارسال همچنان با شماست."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "Modifier les étiquettes et indicateurs de vos e-mails",
|
||||
"body": "Ajouter ou retirer des étiquettes, des indicateurs et d'autres mots-clés sur vos messages."
|
||||
"body": "Ajouter ou retirer des étiquettes, des indicateurs et d'autres mots-clés sur vos messages, et enregistrer des brouillons."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "Modifier les e-mails avant leur envoi",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "Savoir quand l'application démarre et s'arrête",
|
||||
"body": "Être averti lorsque l'application démarre, passe hors ligne ou que vous vous déconnectez."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "Ouvrir l'éditeur pour vous",
|
||||
"body": "Ouvrir un nouveau message avec les destinataires, le texte ou les pièces jointes déjà remplis. C'est toujours vous qui décidez de l'envoyer."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4039,7 +4039,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "שינוי תגים וסימונים בדואר שלכם",
|
||||
"body": "הוספה או הסרה של תגים, סימונים ומילות מפתח אחרות בהודעות שלכם."
|
||||
"body": "הוספה או הסרה של תגים, סימונים ומילות מפתח אחרות בהודעות שלכם, ושמירת טיוטות."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "שינוי דואר לפני שליחתו",
|
||||
@@ -4244,6 +4244,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "לדעת מתי האפליקציה מופעלת ונעצרת",
|
||||
"body": "לקבל עדכון כשהאפליקציה מופעלת, עוברת למצב לא מקוון או כשאתם מתנתקים."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "פתיחת העורך עבורכם",
|
||||
"body": "פתיחת הודעה חדשה עם נמענים, טקסט או קבצים מצורפים שכבר מולאו. ההחלטה אם לשלוח אותה נשארת שלכם."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "Címkék és jelölők módosítása a leveleiden",
|
||||
"body": "Címkéket, jelölőket és más kulcsszavakat ad hozzá az üzeneteidhez, vagy eltávolítja őket."
|
||||
"body": "Címkéket, jelölőket és más kulcsszavakat ad hozzá az üzeneteidhez, vagy eltávolítja őket, és piszkozatokat ment."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "Levelek módosítása küldés előtt",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "Tudomást szerez az alkalmazás indulásáról és leállásáról",
|
||||
"body": "Értesül, amikor az alkalmazás elindul, offline módba vált, vagy kijelentkezel."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "A szerkesztő megnyitása",
|
||||
"body": "Új üzenetet nyit meg kitöltött címzettekkel, szöveggel vagy mellékletekkel. Az elküldésről továbbra is te döntesz."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "Modificare etichette e contrassegni della tua posta",
|
||||
"body": "Aggiungere o rimuovere etichette, contrassegni e altre parole chiave dai tuoi messaggi."
|
||||
"body": "Aggiungere o rimuovere etichette, contrassegni e altre parole chiave dai tuoi messaggi, e salvare bozze."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "Modificare la posta prima dell'invio",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "Sapere quando l'app si avvia e si chiude",
|
||||
"body": "Ricevere un avviso quando l'app si avvia, va offline o ti disconnetti."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "Aprire l'editor per te",
|
||||
"body": "Aprire un nuovo messaggio con destinatari, testo o allegati già compilati. Sei sempre tu a decidere se inviarlo."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "メールのタグとフラグを変更",
|
||||
"body": "メッセージにタグ、フラグ、その他のキーワードを追加したり削除したりできます。"
|
||||
"body": "メッセージにタグ、フラグ、その他のキーワードを追加したり削除したり、下書きを保存したりできます。"
|
||||
},
|
||||
"email_send": {
|
||||
"title": "送信前のメールを変更",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "アプリの起動と終了を知る",
|
||||
"body": "アプリが起動したとき、オフラインになったとき、サインアウトしたときに通知を受けます。"
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "作成画面を開く",
|
||||
"body": "宛先、本文、添付ファイルを入力した状態で新しいメッセージを開きます。送信するかどうかはあなたが決めます。"
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "메일의 태그와 플래그 변경",
|
||||
"body": "메시지에 태그, 플래그 및 기타 키워드를 추가하거나 제거할 수 있어요."
|
||||
"body": "메시지에 태그, 플래그 및 기타 키워드를 추가하거나 제거하고, 임시보관함에 메일을 저장할 수 있어요."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "보내기 전에 메일 변경",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "앱 시작과 종료 알림 받기",
|
||||
"body": "앱이 시작되거나, 오프라인이 되거나, 로그아웃할 때 알림을 받아요."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "작성 창 열기",
|
||||
"body": "받는 사람, 본문, 첨부 파일이 채워진 새 메시지를 열어요. 보낼지는 직접 결정해요."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "Mainīt vēstuļu tagus un karodziņus",
|
||||
"body": "Pievienot vai noņemt tagus, karodziņus un citus atslēgvārdus jūsu vēstulēm."
|
||||
"body": "Pievienot vai noņemt tagus, karodziņus un citus atslēgvārdus jūsu vēstulēm un saglabāt melnrakstus."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "Mainīt vēstules pirms nosūtīšanas",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "Zināt, kad lietotne startē un apstājas",
|
||||
"body": "Saņemt ziņu, kad lietotne startē, pāriet bezsaistē vai jūs izrakstāties."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "Atvērt redaktoru jūsu vietā",
|
||||
"body": "Atvērt jaunu vēstuli ar jau aizpildītiem adresātiem, tekstu vai pielikumiem. Vai to nosūtīt, joprojām izlemjat jūs."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "Имэйлийн таг болон тугийг өөрчлөх",
|
||||
"body": "Таны имэйлүүдэд таг, туг болон бусад түлхүүр үг нэмэх эсвэл хасах."
|
||||
"body": "Таны имэйлүүдэд таг, туг болон бусад түлхүүр үг нэмэх эсвэл хасах, мөн ноорог хадгалах."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "Илгээхээс өмнө имэйлийг өөрчлөх",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "Апп эхлэх, зогсохыг мэдэх",
|
||||
"body": "Апп эхлэх, офлайн болох эсвэл таныг гарах үед мэдэгдэл авах."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "Имэйл бичих цонх нээх",
|
||||
"body": "Хүлээн авагч, текст эсвэл хавсралтыг нь бөглөсөн шинэ имэйл нээх. Илгээх эсэхийг та өөрөө шийднэ."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "Endre etiketter og flagg på e-posten din",
|
||||
"body": "Legge til eller fjerne etiketter, flagg og andre nøkkelord på meldingene dine."
|
||||
"body": "Legge til eller fjerne etiketter, flagg og andre nøkkelord på meldingene dine, og lagre utkast."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "Endre e-post før den sendes",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "Vite når appen starter og stopper",
|
||||
"body": "Få beskjed når appen starter, går frakoblet eller du logger ut."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "Åpne meldingsvinduet for deg",
|
||||
"body": "Åpne en ny melding med mottakere, tekst eller vedlegg fylt ut. Du bestemmer fortsatt om den skal sendes."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "Labels en markeringen van je e-mail wijzigen",
|
||||
"body": "Labels, markeringen en andere trefwoorden aan je berichten toevoegen of ervan verwijderen."
|
||||
"body": "Labels, markeringen en andere trefwoorden aan je berichten toevoegen of ervan verwijderen, en concepten opslaan."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "E-mail wijzigen voordat die wordt verzonden",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "Weten wanneer de app start en stopt",
|
||||
"body": "Een melding krijgen wanneer de app start, offline gaat of je je afmeldt."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "De opsteller voor je openen",
|
||||
"body": "Een nieuw bericht openen met ontvangers, tekst of bijlagen al ingevuld. Jij beslist nog steeds of het wordt verzonden."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "Zmiana etykiet i flag w poczcie",
|
||||
"body": "Dodaje i usuwa etykiety, flagi i inne słowa kluczowe w Twoich wiadomościach."
|
||||
"body": "Dodaje i usuwa etykiety, flagi i inne słowa kluczowe w Twoich wiadomościach oraz zapisuje szkice."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "Zmiana poczty przed wysłaniem",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "Informacja o uruchomieniu i zamknięciu aplikacji",
|
||||
"body": "Otrzymuje informację, gdy aplikacja się uruchamia, przechodzi w tryb offline lub gdy się wylogowujesz."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "Otwieranie edytora wiadomości",
|
||||
"body": "Otwiera nową wiadomość z wypełnionymi odbiorcami, treścią lub załącznikami. O wysłaniu nadal decydujesz Ty."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "Alterar etiquetas e marcações dos seus e-mails",
|
||||
"body": "Adicionar ou remover etiquetas, marcações e outras palavras-chave nas suas mensagens."
|
||||
"body": "Adicionar ou remover etiquetas, marcações e outras palavras-chave nas suas mensagens, e salvar rascunhos."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "Alterar e-mails antes do envio",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "Saber quando o aplicativo inicia e para",
|
||||
"body": "Ser avisado quando o aplicativo inicia, fica offline ou você sai."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "Abrir o editor para você",
|
||||
"body": "Abrir uma nova mensagem com destinatários, texto ou anexos já preenchidos. Você continua decidindo se ela é enviada."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "Modificarea etichetelor și marcajelor e-mailurilor",
|
||||
"body": "Adaugă sau elimină etichete, marcaje și alte cuvinte cheie din mesajele dvs."
|
||||
"body": "Adaugă sau elimină etichete, marcaje și alte cuvinte cheie din mesajele dvs. și salvează ciorne."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "Modificarea e-mailurilor înainte de trimitere",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "Aflarea momentului pornirii și opririi aplicației",
|
||||
"body": "Este anunțat când aplicația pornește, trece offline sau vă deconectați."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "Deschiderea ferestrei de redactare",
|
||||
"body": "Deschide un mesaj nou cu destinatarii, textul sau atașamentele deja completate. Dvs. decideți în continuare dacă îl trimiteți."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "Менять теги и флажки писем",
|
||||
"body": "Добавлять и удалять теги, флажки и другие ключевые слова у ваших писем."
|
||||
"body": "Добавлять и удалять теги, флажки и другие ключевые слова у ваших писем, а также сохранять черновики."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "Менять письма перед отправкой",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "Узнавать о запуске и остановке приложения",
|
||||
"body": "Получать уведомления, когда приложение запускается, теряет связь или вы выходите."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "Открывать редактор письма",
|
||||
"body": "Открывать новое письмо с уже заполненными получателями, текстом или вложениями. Отправлять ли его, по-прежнему решаете вы."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "Meniť štítky a príznaky pošty",
|
||||
"body": "Pridáva alebo odstraňuje štítky, príznaky a ďalšie kľúčové slová pri vašich správach."
|
||||
"body": "Pridáva alebo odstraňuje štítky, príznaky a ďalšie kľúčové slová pri vašich správach a ukladá koncepty."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "Meniť poštu pred odoslaním",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "Vedieť, kedy sa aplikácia spúšťa a ukončuje",
|
||||
"body": "Dostáva správu, keď sa aplikácia spustí, prejde do režimu offline alebo keď sa odhlásite."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "Otvárať editor správy",
|
||||
"body": "Otvorí novú správu s vyplnenými príjemcami, textom alebo prílohami. O odoslaní stále rozhodujete vy."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "E-postalarınızdaki etiketleri ve bayrakları değiştirme",
|
||||
"body": "İletilerinize etiket, bayrak ve diğer anahtar sözcükleri ekleme veya kaldırma."
|
||||
"body": "İletilerinize etiket, bayrak ve diğer anahtar sözcükleri ekleme veya kaldırma ve taslak kaydetme."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "Gönderilmeden önce iletileri değiştirme",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "Uygulamanın başladığını ve durduğunu bilme",
|
||||
"body": "Uygulama başladığında, çevrimdışı olduğunda veya çıkış yaptığınızda haber alma."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "İleti düzenleyiciyi açma",
|
||||
"body": "Alıcıları, metni veya ekleri doldurulmuş yeni bir ileti açma. Gönderip göndermemeye yine siz karar verirsiniz."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "Змінювати теги й позначки листів",
|
||||
"body": "Додавати й прибирати теги, позначки та інші ключові слова у ваших листах."
|
||||
"body": "Додавати й прибирати теги, позначки та інші ключові слова у ваших листах, а також зберігати чернетки."
|
||||
},
|
||||
"email_send": {
|
||||
"title": "Змінювати листи перед надсиланням",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "Знати, коли програма запускається й зупиняється",
|
||||
"body": "Отримувати сповіщення, коли програма запускається, втрачає зв'язок або ви виходите."
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "Відкривати редактор листа",
|
||||
"body": "Відкривати новий лист із уже заповненими одержувачами, текстом або вкладеннями. Надсилати його чи ні, як і раніше, вирішуєте ви."
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "變更郵件的標籤和旗標",
|
||||
"body": "為您的郵件新增或移除標籤、旗標和其他關鍵字。"
|
||||
"body": "為您的郵件新增或移除標籤、旗標和其他關鍵字,並儲存草稿。"
|
||||
},
|
||||
"email_send": {
|
||||
"title": "在傳送前變更郵件",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "得知應用程式何時啟動和停止",
|
||||
"body": "在應用程式啟動、離線或您登出時收到通知。"
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "為您開啟撰寫視窗",
|
||||
"body": "開啟一封已填好收件者、內文或附件的新郵件。是否傳送仍由您決定。"
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
@@ -4038,7 +4038,7 @@
|
||||
},
|
||||
"email_write": {
|
||||
"title": "更改邮件的标签和旗标",
|
||||
"body": "为您的邮件添加或移除标签、旗标和其他关键词。"
|
||||
"body": "为您的邮件添加或移除标签、旗标和其他关键词,并保存草稿。"
|
||||
},
|
||||
"email_send": {
|
||||
"title": "在发送前更改邮件",
|
||||
@@ -4243,6 +4243,10 @@
|
||||
"app_lifecycle": {
|
||||
"title": "得知应用何时启动和停止",
|
||||
"body": "在应用启动、离线或您退出登录时收到通知。"
|
||||
},
|
||||
"compose_open": {
|
||||
"title": "为您打开撰写窗口",
|
||||
"body": "打开一封已填好收件人、正文或附件的新邮件。是否发送仍由您决定。"
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
|
||||
Reference in New Issue
Block a user