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": {