diff --git a/components/mail/__tests__/mailto-draft.test.ts b/components/mail/__tests__/mailto-draft.test.ts
new file mode 100644
index 000000000..33756ba47
--- /dev/null
+++ b/components/mail/__tests__/mailto-draft.test.ts
@@ -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: "
Hello you
" }, true);
+ expect(draft.body).toBe("Hello you
");
+ 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);
+ });
+});
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 4eca088ca..3000636f0 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -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");
diff --git a/components/mail/mailto-draft.ts b/components/mail/mailto-draft.ts
new file mode 100644
index 000000000..2adeb5c52
--- /dev/null
+++ b/components/mail/mailto-draft.ts
@@ -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 } : {}),
+ };
+}
diff --git a/lib/__tests__/plugin-host-compose-drafts.test.ts b/lib/__tests__/plugin-host-compose-drafts.test.ts
new file mode 100644
index 000000000..64616ed15
--- /dev/null
+++ b/lib/__tests__/plugin-host-compose-drafts.test.ts
@@ -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).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 '], cc: ['carol@example.com'], subject: 'Report', body: 'See attached', attachments: [pdf],
+ })).toBe('opened');
+ expect(taken).toEqual([{
+ to: ['Bob '], 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: 'Hi there
', isHtml: true });
+ expect(taken[0].htmlBody).toContain('there');
+ 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;
+
+ 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: 'Hello
', 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('Hello
');
+ 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);
+ });
+});
diff --git a/lib/plugin-sandbox/host-api-compose.ts b/lib/plugin-sandbox/host-api-compose.ts
new file mode 100644
index 000000000..c3f77c664
--- /dev/null
+++ b/lib/plugin-sandbox/host-api-compose.ts
@@ -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): 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();
+
+/** 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;
+ 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 {
+ const method = 'drafts.create';
+ const data = readObject(input, method, 'draft', ['to', 'cc', 'bcc', 'subject', 'body', 'isHtml', 'attachments'], true) as DraftInput & Record;
+ 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,
+ );
+}
diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts
index 07ace0745..9a0666b6e 100644
--- a/lib/plugin-sandbox/host-api.ts
+++ b/lib/plugin-sandbox/host-api.ts
@@ -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> = {
// 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}"`);
}
diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx
index 24ad08211..f8dd275ff 100644
--- a/lib/plugin-sandbox/runtime.tsx
+++ b/lib/plugin-sandbox/runtime.tsx
@@ -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,
},
+ // 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,
+ },
// Folders (permission: email:read).
mailboxes: {
/** Every folder with its role, parent and message counts. */
diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts
index 213b2f2e0..9ca2ffaec 100644
--- a/lib/plugin-types.ts
+++ b/lib/plugin-types.ts
@@ -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 `; 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',
diff --git a/lib/protocol-handlers/mailto.ts b/lib/protocol-handlers/mailto.ts
index 765bd6040..c1667f320 100644
--- a/lib/protocol-handlers/mailto.ts
+++ b/lib/protocol-handlers/mailto.ts
@@ -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(INTERNAL_MAILTO_EVENT, { detail: parsed, cancelable: true }),
+ new CustomEvent(INTERNAL_MAILTO_EVENT, { detail: draft, cancelable: true }),
);
return !uncancelled;
}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 69e42e6d4..08bd39d0e 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -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": {
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 3faa1e74a..a7bf6e941 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -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": {
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 40930c54e..6f0c44b69 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -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": {
diff --git a/locales/da/common.json b/locales/da/common.json
index c0656d684..dfa29fd4e 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -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": {
diff --git a/locales/de/common.json b/locales/de/common.json
index ee55d757a..73aba7ce0 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -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": {
diff --git a/locales/en/common.json b/locales/en/common.json
index 591e34c8a..65a7b4b53 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -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": {
diff --git a/locales/es/common.json b/locales/es/common.json
index b1d18d971..7ac46e075 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -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": {
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 93acc0672..9f5686988 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -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": {
diff --git a/locales/fr/common.json b/locales/fr/common.json
index d78fb6cf9..c29436d86 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -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": {
diff --git a/locales/he/common.json b/locales/he/common.json
index 1e2daf712..40a968d75 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -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": {
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 9cab011e0..e3462daa5 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -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": {
diff --git a/locales/it/common.json b/locales/it/common.json
index 9e9c38246..74c139964 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -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": {
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 38aaae9ee..9194c7aff 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -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": {
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 6cd559931..cb07b8611 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -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": {
diff --git a/locales/lv/common.json b/locales/lv/common.json
index deb525349..a672e2bc7 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -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": {
diff --git a/locales/mn/common.json b/locales/mn/common.json
index fd03bcba9..653eae396 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -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": {
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 714a734e8..564870a89 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -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": {
diff --git a/locales/nl/common.json b/locales/nl/common.json
index b1c14453d..c0713f0e8 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -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": {
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 63ac73b11..d0a547cfb 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -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": {
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 5a961d810..5fa738632 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -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": {
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 00931022e..3d5ad23e8 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -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": {
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 28c13937c..db900b227 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -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": {
diff --git a/locales/sk/common.json b/locales/sk/common.json
index b5463d6ae..01931fdb7 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -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": {
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 69d47d3b0..a87ebd6ba 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -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": {
diff --git a/locales/uk/common.json b/locales/uk/common.json
index aeb6cf702..54db0aab7 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -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": {
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index d3d9157c1..c5fad9cf0 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -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": {
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 303ae4f19..ff2d665ca 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -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": {