feat: let plugins open a filled-in composer and save drafts

This commit is contained in:
Linus Rath
2026-10-14 04:23:17 +02:00
parent 08e2d65944
commit ead925c1b8
36 changed files with 582 additions and 47 deletions
@@ -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);
});
});
+3 -18
View File
@@ -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");
+34
View File
@@ -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);
});
});
+154
View File
@@ -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,
);
}
+8
View File
@@ -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}"`);
}
+22 -1
View File
@@ -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. */
+37
View File
@@ -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',
+18 -1
View File
@@ -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;
}
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {
+5 -1
View File
@@ -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": {