Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e94508ea89 | ||
|
|
6190d2b4f5 | ||
|
|
ed23610e31 | ||
|
|
3e044057f6 | ||
|
|
69ccb313e3 | ||
|
|
29795f3988 | ||
|
|
38dcbd9e0c | ||
|
|
5fe929c6a4 | ||
|
|
c2502d36bd | ||
|
|
b11f3997d4 | ||
|
|
f6320e6614 | ||
|
|
152311b035 | ||
|
|
3434a5ed39 | ||
|
|
64b5db01f3 | ||
|
|
af9b3a7f70 | ||
|
|
8a670c5e70 | ||
|
|
cfb28ada45 | ||
|
|
7b9da44116 | ||
|
|
300ec1df99 | ||
|
|
3f19f82960 | ||
|
|
2e7f30c64e | ||
|
|
c558693db5 | ||
|
|
c87a6d1cd6 | ||
|
|
34d3f21b4b | ||
|
|
0c5ad7a3b8 |
No files matched your search
@@ -1,17 +1,26 @@
|
|||||||
# Announce each published release on the community forum, in this project's
|
# Announce each published release on the community forum, in this project's
|
||||||
# Announcements category (coffey-labs/actions discourse-release; the repo ->
|
# Announcements category (coffey-labs/actions discourse-release; the repo ->
|
||||||
# category map is its release-map.json). Safe to re-run: one topic per tag.
|
# category map is its release-map.json). Safe to re-run: one topic per tag.
|
||||||
|
# Run it by hand with a tag to announce a release whose own run failed: a
|
||||||
|
# release event runs the workflow as it was at the tag, so a fix on main only
|
||||||
|
# reaches an old release this way.
|
||||||
name: announce
|
name: announce
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: release tag to announce, e.g. inbuxa-v2026.10.5-g17a8093
|
||||||
|
required: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
announce:
|
announce:
|
||||||
runs-on: light
|
runs-on: light
|
||||||
steps:
|
steps:
|
||||||
- uses: coffey-labs/actions/discourse-release@e9293996e2efa770839121fa8f8da93083f216be
|
- uses: coffey-labs/actions/discourse-release@9282c6f27303f4eb9d61d6aee6daa444c11d7268
|
||||||
with:
|
with:
|
||||||
api-key: ${{ secrets.DISCOURSE_RELEASE_KEY }}
|
api-key: ${{ secrets.DISCOURSE_RELEASE_KEY }}
|
||||||
discord-webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
discord-webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
||||||
|
tag: ${{ inputs.tag }}
|
||||||
@@ -203,7 +203,7 @@ jobs:
|
|||||||
if: ${{ always() && startsWith(github.ref, 'refs/tags/inbuxa-v') && (needs.publish.result == 'success' || needs.github.result == 'success') }}
|
if: ${{ always() && startsWith(github.ref, 'refs/tags/inbuxa-v') && (needs.publish.result == 'success' || needs.github.result == 'success') }}
|
||||||
runs-on: light
|
runs-on: light
|
||||||
steps:
|
steps:
|
||||||
- uses: coffey-labs/actions/discourse-release@e9293996e2efa770839121fa8f8da93083f216be
|
- uses: coffey-labs/actions/discourse-release@9282c6f27303f4eb9d61d6aee6daa444c11d7268
|
||||||
with:
|
with:
|
||||||
api-key: ${{ secrets.DISCOURSE_RELEASE_KEY }}
|
api-key: ${{ secrets.DISCOURSE_RELEASE_KEY }}
|
||||||
discord-webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
discord-webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ settings included, lives on the server, so the container is disposable.
|
|||||||
Everything else is in INBUXA Admin.
|
Everything else is in INBUXA Admin.
|
||||||
- **Sign-in on the mail server's own page**, two-factor included. The webmail
|
- **Sign-in on the mail server's own page**, two-factor included. The webmail
|
||||||
never handles a password to sign someone in, and holds only sealed tokens.
|
never handles a password to sign someone in, and holds only sealed tokens.
|
||||||
- **Ten interface languages and twelve themes.**
|
- **Eleven interface languages and twelve themes.**
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
+13
-12
@@ -19,7 +19,7 @@ import { ComposerDock } from "@/views/compose/ComposerDock";
|
|||||||
import { requestNotificationPermission, setBaseTitle, setUnreadBadge } from "@/lib/notify/notify";
|
import { requestNotificationPermission, setBaseTitle, setUnreadBadge } from "@/lib/notify/notify";
|
||||||
import { publishWorkerFacts } from "@/lib/sw/swFacts";
|
import { publishWorkerFacts } from "@/lib/sw/swFacts";
|
||||||
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
|
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
|
||||||
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync";
|
import { armSettingsSync, loadRemoteSettings, loadSettingsOnce, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
|
||||||
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
|
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
|
||||||
import { listenForVerification, renewWebPush } from "@/lib/notify/webpushEnable";
|
import { listenForVerification, renewWebPush } from "@/lib/notify/webpushEnable";
|
||||||
import { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
|
import { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
|
||||||
@@ -141,22 +141,22 @@ function AuthedApp() {
|
|||||||
* Once per account, not once per mount: this subtree is keyed on the
|
* Once per account, not once per mount: this subtree is keyed on the
|
||||||
* language version, so picking a language throws it away and builds it
|
* language version, so picking a language throws it away and builds it
|
||||||
* again. Re-reading the settings file there would apply a copy written
|
* again. Re-reading the settings file there would apply a copy written
|
||||||
* before the change and undo it.
|
* before the change and undo it. And the load is not this mount's to
|
||||||
|
* cancel: the settings file choosing a language remounts the tree midway
|
||||||
|
* through it, and a load cut off there never armed the pushes, so nothing
|
||||||
|
* changed afterwards was saved (Gitea issue #23). `loadSettingsOnce` runs
|
||||||
|
* it to the end and lets every mount wait on it.
|
||||||
*/
|
*/
|
||||||
const [ready, setReady] = useState(PAINTED_FROM_CACHE);
|
const [ready, setReady] = useState(PAINTED_FROM_CACHE);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settingsAlreadyLoadedFor(accountId)) {
|
|
||||||
setReady(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let canceled = false;
|
let canceled = false;
|
||||||
void (async () => {
|
void loadSettingsOnce(accountId, async (isCurrent) => {
|
||||||
/* Before the account's own settings, so both the seeding below and the
|
/* Before the account's own settings, so both the seeding below and the
|
||||||
enforcement inside `hydrate` have something to apply. */
|
enforcement inside `hydrate` have something to apply. */
|
||||||
await loadSettingsPolicy();
|
await loadSettingsPolicy();
|
||||||
if (canceled) return;
|
if (!isCurrent()) return;
|
||||||
const remote = await loadRemoteSettings();
|
const remote = await loadRemoteSettings();
|
||||||
if (canceled) return;
|
if (!isCurrent()) return;
|
||||||
if (remote) useSettings.getState().hydrate(remote);
|
if (remote) useSettings.getState().hydrate(remote);
|
||||||
// No settings file: this account has never had settings of its own, so
|
// No settings file: this account has never had settings of its own, so
|
||||||
// the installation's defaults are what it starts on rather than
|
// the installation's defaults are what it starts on rather than
|
||||||
@@ -178,15 +178,16 @@ function AuthedApp() {
|
|||||||
// The catalog for whatever language that turned out to be. Hydrating
|
// The catalog for whatever language that turned out to be. Hydrating
|
||||||
// asks for it; this is waiting for the answer.
|
// asks for it; this is waiting for the answer.
|
||||||
await whenLanguageReady();
|
await whenLanguageReady();
|
||||||
if (canceled) return;
|
if (!isCurrent()) return;
|
||||||
setReady(true);
|
|
||||||
// Pushes were held back until now so they could not race the load. A
|
// Pushes were held back until now so they could not race the load. A
|
||||||
// change made while it was in flight was kept, and goes out here.
|
// change made while it was in flight was kept, and goes out here.
|
||||||
armSettingsSync();
|
armSettingsSync();
|
||||||
// No file yet — seed one from what this browser has, so the next device
|
// No file yet — seed one from what this browser has, so the next device
|
||||||
// to sign in starts from these rather than from the defaults.
|
// to sign in starts from these rather than from the defaults.
|
||||||
if (!remote && settingsSyncAvailable()) queueSettingsPush(syncedPart(useSettings.getState().settings));
|
if (!remote && settingsSyncAvailable()) queueSettingsPush(syncedPart(useSettings.getState().settings));
|
||||||
})();
|
}).then(() => {
|
||||||
|
if (!canceled) setReady(true);
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
canceled = true;
|
canceled = true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, mergeRemote, syncedPart, type Settings } from "@/store/settings";
|
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, mergeRemote, syncedPart, type Settings } from "@/store/settings";
|
||||||
import { isAppFolder } from "../appFolder";
|
import { isAppFolder } from "../appFolder";
|
||||||
import { settingsAlreadyLoadedFor, stopSettingsSync } from "../settingsSync";
|
import { loadSettingsOnce, stopSettingsSync } from "../settingsSync";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings used to live only in localStorage, so nothing followed the user
|
* Settings used to live only in localStorage, so nothing followed the user
|
||||||
@@ -116,20 +116,67 @@ describe("a change made but not yet written up", () => {
|
|||||||
expect(merged.uiLanguage).toBe("en");
|
expect(merged.uiLanguage).toBe("en");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads the file again for an account after a sign-out", () => {
|
it("reads the file again for an account after a sign-out", async () => {
|
||||||
stopSettingsSync();
|
stopSettingsSync();
|
||||||
expect(settingsAlreadyLoadedFor("a1")).toBe(false);
|
let loads = 0;
|
||||||
|
const load = async () => { loads++; };
|
||||||
|
await loadSettingsOnce("a1", load);
|
||||||
// The remount that a language change causes must not read it a second time.
|
// The remount that a language change causes must not read it a second time.
|
||||||
expect(settingsAlreadyLoadedFor("a1")).toBe(true);
|
await loadSettingsOnce("a1", load);
|
||||||
|
expect(loads).toBe(1);
|
||||||
// Signing out drops the claim, so signing back in reads the file rather
|
// Signing out drops the claim, so signing back in reads the file rather
|
||||||
// than trusting whatever the previous session left behind.
|
// than trusting whatever the previous session left behind.
|
||||||
stopSettingsSync();
|
stopSettingsSync();
|
||||||
expect(settingsAlreadyLoadedFor("a1")).toBe(false);
|
await loadSettingsOnce("a1", load);
|
||||||
|
expect(loads).toBe(2);
|
||||||
stopSettingsSync();
|
stopSettingsSync();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats a missing account as already loaded, so nothing is fetched", () => {
|
it("finishes a load that a remount interrupts, so changes are saved (Gitea #23)", async () => {
|
||||||
expect(settingsAlreadyLoadedFor(null)).toBe(true);
|
stopSettingsSync();
|
||||||
expect(settingsAlreadyLoadedFor(undefined)).toBe(true);
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>((r) => { release = r; });
|
||||||
|
let loads = 0;
|
||||||
|
let armed = false;
|
||||||
|
const load = async (isCurrent: () => boolean) => {
|
||||||
|
loads++;
|
||||||
|
await gate;
|
||||||
|
if (!isCurrent()) return;
|
||||||
|
armed = true;
|
||||||
|
};
|
||||||
|
// First mount starts the load; the settings file picks a language and the
|
||||||
|
// tree remounts before the load has finished.
|
||||||
|
const first = loadSettingsOnce("a1", load);
|
||||||
|
const second = loadSettingsOnce("a1", load);
|
||||||
|
expect(second).toBe(first);
|
||||||
|
release();
|
||||||
|
await second;
|
||||||
|
expect(loads).toBe(1);
|
||||||
|
// This is what used to be skipped: the remounted tree took the
|
||||||
|
// already-loaded path and nothing armed the pushes.
|
||||||
|
expect(armed).toBe(true);
|
||||||
|
stopSettingsSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops a load that a sign-out overtakes", async () => {
|
||||||
|
stopSettingsSync();
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>((r) => { release = r; });
|
||||||
|
let applied = false;
|
||||||
|
const pending = loadSettingsOnce("a1", async (isCurrent) => {
|
||||||
|
await gate;
|
||||||
|
if (isCurrent()) applied = true;
|
||||||
|
});
|
||||||
|
stopSettingsSync();
|
||||||
|
release();
|
||||||
|
await pending;
|
||||||
|
expect(applied).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a missing account as already loaded, so nothing is fetched", async () => {
|
||||||
|
let loads = 0;
|
||||||
|
await loadSettingsOnce(null, async () => { loads++; });
|
||||||
|
await loadSettingsOnce(undefined, async () => { loads++; });
|
||||||
|
expect(loads).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -48,6 +48,7 @@ export const UI_LANGUAGES: readonly UiLanguage[] = [
|
|||||||
{ tag: "ru", name: "Русский" },
|
{ tag: "ru", name: "Русский" },
|
||||||
{ tag: "uk", name: "Українська" },
|
{ tag: "uk", name: "Українська" },
|
||||||
{ tag: "zh-Hans", name: "简体中文" },
|
{ tag: "zh-Hans", name: "简体中文" },
|
||||||
|
{ tag: "tr", name: "Türkçe" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Where to report a bad translation. Beta languages depend on it. */
|
/** Where to report a bad translation. Beta languages depend on it. */
|
||||||
|
|||||||
+28
-10
@@ -34,6 +34,9 @@ let inFlight: Promise<void> | null = null;
|
|||||||
/** Nothing is pushed before the first load has settled, or we would race it. */
|
/** Nothing is pushed before the first load has settled, or we would race it. */
|
||||||
let armed = false;
|
let armed = false;
|
||||||
let loadedFor: string | null = null;
|
let loadedFor: string | null = null;
|
||||||
|
let loading: Promise<void> | null = null;
|
||||||
|
/** Bumped by every new load and every sign-out; a load checks it is still the latest. */
|
||||||
|
let generation = 0;
|
||||||
let listenersBound = false;
|
let listenersBound = false;
|
||||||
|
|
||||||
export function settingsSyncAvailable(): boolean {
|
export function settingsSyncAvailable(): boolean {
|
||||||
@@ -64,21 +67,34 @@ export async function loadRemoteSettings(): Promise<Record<string, unknown> | nu
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Has this account's settings file already been read on this page load?
|
* Load this account's settings, once per page load, however many times the
|
||||||
|
* caller mounts.
|
||||||
*
|
*
|
||||||
* Claims the account as a side effect, so two callers cannot both start a
|
* The subtree that does the reading is keyed on the language version, so it
|
||||||
* read. The subtree that does the reading is keyed on the language version
|
* is remounted whenever the language changes -- including when the settings
|
||||||
* and so is deliberately remounted whenever somebody picks a language;
|
* file itself picks one. Tying the load to a mount meant that remount canceled
|
||||||
* without this the remount re-reads a file written before the change and
|
* it halfway: the new mount saw the account already claimed and skipped the
|
||||||
* applies it, putting the old language back.
|
* load, and nothing armed the pushes, so every later change was silently
|
||||||
|
* dropped (Gitea issue #23). Now the load belongs to the account, not the
|
||||||
|
* mount: every mount waits on the same promise, and the load runs to the end.
|
||||||
*
|
*
|
||||||
|
* Re-reading on a remount is still wrong -- it would apply a file written
|
||||||
|
* before the change and undo it -- which is why it is shared, not repeated.
|
||||||
|
*
|
||||||
|
* `isCurrent` turns false once the session that started the load signs out,
|
||||||
|
* so a load overtaken by a sign-out stops instead of applying stale settings.
|
||||||
* Cleared by `stopSettingsSync`, so signing out and back in reads again.
|
* Cleared by `stopSettingsSync`, so signing out and back in reads again.
|
||||||
*/
|
*/
|
||||||
export function settingsAlreadyLoadedFor(accountId: string | null | undefined): boolean {
|
export function loadSettingsOnce(
|
||||||
if (!accountId) return true;
|
accountId: string | null | undefined,
|
||||||
if (loadedFor === accountId) return true;
|
load: (isCurrent: () => boolean) => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!accountId) return Promise.resolve();
|
||||||
|
if (loadedFor === accountId && loading) return loading;
|
||||||
loadedFor = accountId;
|
loadedFor = accountId;
|
||||||
return false;
|
const mine = ++generation;
|
||||||
|
loading = load(() => generation === mine);
|
||||||
|
return loading;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Allow pushes. Called once the first load has settled, either way. */
|
/** Allow pushes. Called once the first load has settled, either way. */
|
||||||
@@ -93,6 +109,8 @@ export function armSettingsSync(): void {
|
|||||||
export function stopSettingsSync(): void {
|
export function stopSettingsSync(): void {
|
||||||
armed = false;
|
armed = false;
|
||||||
loadedFor = null;
|
loadedFor = null;
|
||||||
|
loading = null;
|
||||||
|
generation++;
|
||||||
pending = null;
|
pending = null;
|
||||||
if (timer !== null) {
|
if (timer !== null) {
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
|
|||||||
@@ -387,3 +387,16 @@ describe("the containment that mail CSS cannot override", () => {
|
|||||||
expect(rule).toMatch(/contain\s*:\s*layout/);
|
expect(rule).toMatch(/contain\s*:\s*layout/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("mail wider than the reading pane", () => {
|
||||||
|
/*
|
||||||
|
* The root keeps `contain: content`, which clips what overflows, so without
|
||||||
|
* a scroll the right side of a min-width table was simply gone, worst on a
|
||||||
|
* phone (Gitea issue #24). jsdom does no layout, so this pins the rule.
|
||||||
|
*/
|
||||||
|
it("scrolls sideways instead of being cut off", () => {
|
||||||
|
const root = EMAIL_BASE_CSS.match(/\.ihm-email-root \{([^}]*)\}/)?.[1] ?? "";
|
||||||
|
expect(root).toMatch(/contain:\s*content/);
|
||||||
|
expect(root).toMatch(/overflow-x:\s*auto/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -287,7 +287,10 @@ export function sanitizeEditorHtml(input: string): string {
|
|||||||
export const EMAIL_BASE_CSS = `
|
export const EMAIL_BASE_CSS = `
|
||||||
:host { display:block; color-scheme: light; }
|
:host { display:block; color-scheme: light; }
|
||||||
:host(.themed) { color-scheme: inherit; }
|
:host(.themed) { color-scheme: inherit; }
|
||||||
.ihm-email-root { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color:#1f2937; background:#fff; padding:16px; border-radius:8px; overflow-wrap:anywhere; word-break:normal; contain: content; }
|
/* contain: content clips what overflows, so mail built wider than the pane --
|
||||||
|
a min-width table, a nowrap cell -- was cut off with no way to reach the
|
||||||
|
rest. It scrolls sideways instead (Gitea issue #24). */
|
||||||
|
.ihm-email-root { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color:#1f2937; background:#fff; padding:16px; border-radius:8px; overflow-wrap:anywhere; word-break:normal; contain: content; overflow-x:auto; overflow-y:hidden; }
|
||||||
.ihm-email-root img { max-width:100%; height:auto; }
|
.ihm-email-root img { max-width:100%; height:auto; }
|
||||||
.ihm-email-root img[data-ihm-blocked] { display:inline-block; min-width:16px; min-height:16px; background:#f1f5f9 repeating-linear-gradient(45deg,#e2e8f0 0 6px,#f1f5f9 6px 12px); border:1px dashed #cbd5e1; }
|
.ihm-email-root img[data-ihm-blocked] { display:inline-block; min-width:16px; min-height:16px; background:#f1f5f9 repeating-linear-gradient(45deg,#e2e8f0 0 6px,#f1f5f9 6px 12px); border:1px dashed #cbd5e1; }
|
||||||
.ihm-email-root table { max-width:100%; }
|
.ihm-email-root table { max-width:100%; }
|
||||||
|
|||||||
@@ -651,6 +651,12 @@ export const catalog: Catalog = {
|
|||||||
"+{n} more": "+{n} weitere",
|
"+{n} more": "+{n} weitere",
|
||||||
|
|
||||||
"Contacts": "Kontakte",
|
"Contacts": "Kontakte",
|
||||||
|
|
||||||
|
"Collected": "Gesammelt",
|
||||||
|
|
||||||
|
"Save people you write to as contacts": "Personen, denen Sie schreiben, als Kontakte speichern",
|
||||||
|
|
||||||
|
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Alle, an die Sie senden und die noch keine Kontakte sind, werden dem Adressbuch „Gesammelt“ hinzugefügt und auf jedem Gerät vorgeschlagen. Ihre eigenen Adressen werden nie hinzugefügt.",
|
||||||
"Contacts are not available": "Kontakte sind nicht verfügbar",
|
"Contacts are not available": "Kontakte sind nicht verfügbar",
|
||||||
"New contact": "Neuer Kontakt",
|
"New contact": "Neuer Kontakt",
|
||||||
"Edit contact": "Kontakt bearbeiten",
|
"Edit contact": "Kontakt bearbeiten",
|
||||||
@@ -732,6 +738,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────
|
||||||
"Settings": "Einstellungen",
|
"Settings": "Einstellungen",
|
||||||
|
"Show folder list": "Ordnerliste einblenden",
|
||||||
|
"Hide folder list": "Ordnerliste ausblenden",
|
||||||
"All settings": "Alle Einstellungen",
|
"All settings": "Alle Einstellungen",
|
||||||
"Sections": "Bereiche",
|
"Sections": "Bereiche",
|
||||||
"General": "Allgemein",
|
"General": "Allgemein",
|
||||||
@@ -812,6 +820,9 @@ export const catalog: Catalog = {
|
|||||||
"Text size": "Schriftgröße",
|
"Text size": "Schriftgröße",
|
||||||
"Font size": "Schriftgröße",
|
"Font size": "Schriftgröße",
|
||||||
"Small": "Klein",
|
"Small": "Klein",
|
||||||
|
"Original size": "Originalgröße",
|
||||||
|
"Drag to resize": "Zum Ändern der Größe ziehen",
|
||||||
|
"Image size": "Bildgröße",
|
||||||
"Medium": "Mittel",
|
"Medium": "Mittel",
|
||||||
"Large": "Groß",
|
"Large": "Groß",
|
||||||
"Huge": "Sehr groß",
|
"Huge": "Sehr groß",
|
||||||
@@ -1352,6 +1363,9 @@ export const catalog: Catalog = {
|
|||||||
"Send anyway": "Trotzdem senden",
|
"Send anyway": "Trotzdem senden",
|
||||||
"Send canceled — the message is back in Drafts": "Senden abgebrochen – die Nachricht liegt wieder in den Entwürfen",
|
"Send canceled — the message is back in Drafts": "Senden abgebrochen – die Nachricht liegt wieder in den Entwürfen",
|
||||||
"Send failed: {error}": "Senden fehlgeschlagen: {error}",
|
"Send failed: {error}": "Senden fehlgeschlagen: {error}",
|
||||||
|
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Es konnte nicht geprüft werden, ob diese Nachricht schon gesendet wurde. Prüfen Sie „Gesendet“, bevor Sie sie erneut senden.",
|
||||||
|
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Es konnte nicht bestätigt werden, ob diese Nachricht gesendet wurde. Prüfen Sie „Gesendet“, bevor Sie sie erneut senden.",
|
||||||
|
"This message had already been sent, so it wasn't sent again.": "Diese Nachricht war bereits gesendet und wurde nicht noch einmal gesendet.",
|
||||||
"Send invites": "Einladungen senden",
|
"Send invites": "Einladungen senden",
|
||||||
"Send scheduled for {when}": "Senden geplant für {when}",
|
"Send scheduled for {when}": "Senden geplant für {when}",
|
||||||
"Send without a subject?": "Ohne Betreff senden?",
|
"Send without a subject?": "Ohne Betreff senden?",
|
||||||
|
|||||||
@@ -643,6 +643,12 @@ export const catalog: Catalog = {
|
|||||||
"+{n} more": "+{n} más",
|
"+{n} more": "+{n} más",
|
||||||
|
|
||||||
"Contacts": "Contactos",
|
"Contacts": "Contactos",
|
||||||
|
|
||||||
|
"Collected": "Recopilados",
|
||||||
|
|
||||||
|
"Save people you write to as contacts": "Guardar como contactos a las personas a las que escribes",
|
||||||
|
|
||||||
|
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Todas las personas a las que envías y que aún no son contactos se añaden a la libreta Recopilados, para que se sugieran en todos tus dispositivos. Tus propias direcciones nunca se añaden.",
|
||||||
"Contacts are not available": "Los contactos no están disponibles",
|
"Contacts are not available": "Los contactos no están disponibles",
|
||||||
"New contact": "Contacto nuevo",
|
"New contact": "Contacto nuevo",
|
||||||
"Edit contact": "Editar el contacto",
|
"Edit contact": "Editar el contacto",
|
||||||
@@ -727,6 +733,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────
|
||||||
"Settings": "Configuración",
|
"Settings": "Configuración",
|
||||||
|
"Show folder list": "Mostrar lista de carpetas",
|
||||||
|
"Hide folder list": "Ocultar lista de carpetas",
|
||||||
"All settings": "Toda la configuración",
|
"All settings": "Toda la configuración",
|
||||||
"Sections": "Secciones",
|
"Sections": "Secciones",
|
||||||
"General": "General",
|
"General": "General",
|
||||||
@@ -808,6 +816,9 @@ export const catalog: Catalog = {
|
|||||||
"Text size": "Tamaño del texto",
|
"Text size": "Tamaño del texto",
|
||||||
"Font size": "Tamaño de letra",
|
"Font size": "Tamaño de letra",
|
||||||
"Small": "Pequeño",
|
"Small": "Pequeño",
|
||||||
|
"Original size": "Tamaño original",
|
||||||
|
"Drag to resize": "Arrastra para cambiar el tamaño",
|
||||||
|
"Image size": "Tamaño de la imagen",
|
||||||
"Medium": "Mediano",
|
"Medium": "Mediano",
|
||||||
"Large": "Grande",
|
"Large": "Grande",
|
||||||
"Huge": "Muy grande",
|
"Huge": "Muy grande",
|
||||||
@@ -1325,6 +1336,9 @@ export const catalog: Catalog = {
|
|||||||
"Send anyway": "Enviar de todos modos",
|
"Send anyway": "Enviar de todos modos",
|
||||||
"Send canceled — the message is back in Drafts": "Envío cancelado: el mensaje ha vuelto a Borradores",
|
"Send canceled — the message is back in Drafts": "Envío cancelado: el mensaje ha vuelto a Borradores",
|
||||||
"Send failed: {error}": "No se pudo enviar: {error}",
|
"Send failed: {error}": "No se pudo enviar: {error}",
|
||||||
|
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "No se pudo comprobar si este mensaje ya se envió. Revisa Enviados antes de volver a enviarlo.",
|
||||||
|
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "No se pudo confirmar si este mensaje se envió. Revisa Enviados antes de volver a enviarlo.",
|
||||||
|
"This message had already been sent, so it wasn't sent again.": "Este mensaje ya se había enviado, así que no se ha vuelto a enviar.",
|
||||||
"Send invites": "Enviar invitaciones",
|
"Send invites": "Enviar invitaciones",
|
||||||
"Send scheduled for {when}": "Envío programado para {when}",
|
"Send scheduled for {when}": "Envío programado para {when}",
|
||||||
"Send without a subject?": "¿Enviar sin asunto?",
|
"Send without a subject?": "¿Enviar sin asunto?",
|
||||||
|
|||||||
@@ -648,6 +648,12 @@ export const catalog: Catalog = {
|
|||||||
"+{n} more": "+{n} autres",
|
"+{n} more": "+{n} autres",
|
||||||
|
|
||||||
"Contacts": "Contacts",
|
"Contacts": "Contacts",
|
||||||
|
|
||||||
|
"Collected": "Collectés",
|
||||||
|
|
||||||
|
"Save people you write to as contacts": "Enregistrer comme contacts les personnes à qui vous écrivez",
|
||||||
|
|
||||||
|
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Toute personne à qui vous écrivez et qui n’est pas encore un contact est ajoutée au carnet Collectés, pour vous être proposée sur tous vos appareils. Vos propres adresses ne sont jamais ajoutées.",
|
||||||
"Contacts are not available": "Les contacts ne sont pas disponibles",
|
"Contacts are not available": "Les contacts ne sont pas disponibles",
|
||||||
"New contact": "Nouveau contact",
|
"New contact": "Nouveau contact",
|
||||||
"Edit contact": "Modifier le contact",
|
"Edit contact": "Modifier le contact",
|
||||||
@@ -733,6 +739,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────
|
||||||
"Settings": "Paramètres",
|
"Settings": "Paramètres",
|
||||||
|
"Show folder list": "Afficher la liste des dossiers",
|
||||||
|
"Hide folder list": "Masquer la liste des dossiers",
|
||||||
"All settings": "Tous les paramètres",
|
"All settings": "Tous les paramètres",
|
||||||
"Sections": "Sections",
|
"Sections": "Sections",
|
||||||
"General": "Général",
|
"General": "Général",
|
||||||
@@ -814,6 +822,9 @@ export const catalog: Catalog = {
|
|||||||
"Text size": "Taille du texte",
|
"Text size": "Taille du texte",
|
||||||
"Font size": "Taille de police",
|
"Font size": "Taille de police",
|
||||||
"Small": "Petite",
|
"Small": "Petite",
|
||||||
|
"Original size": "Taille d’origine",
|
||||||
|
"Drag to resize": "Faites glisser pour redimensionner",
|
||||||
|
"Image size": "Taille de l’image",
|
||||||
"Medium": "Moyenne",
|
"Medium": "Moyenne",
|
||||||
"Large": "Grande",
|
"Large": "Grande",
|
||||||
"Huge": "Très grande",
|
"Huge": "Très grande",
|
||||||
@@ -1330,6 +1341,9 @@ export const catalog: Catalog = {
|
|||||||
"Send anyway": "Envoyer quand même",
|
"Send anyway": "Envoyer quand même",
|
||||||
"Send canceled — the message is back in Drafts": "Envoi annulé : le message est de retour dans les brouillons",
|
"Send canceled — the message is back in Drafts": "Envoi annulé : le message est de retour dans les brouillons",
|
||||||
"Send failed: {error}": "Échec de l’envoi : {error}",
|
"Send failed: {error}": "Échec de l’envoi : {error}",
|
||||||
|
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Impossible de vérifier si ce message a déjà été envoyé. Consultez Envoyés avant de le renvoyer.",
|
||||||
|
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Impossible de confirmer l’envoi de ce message. Consultez Envoyés avant de le renvoyer.",
|
||||||
|
"This message had already been sent, so it wasn't sent again.": "Ce message avait déjà été envoyé, il n’a donc pas été renvoyé.",
|
||||||
"Send invites": "Envoyer les invitations",
|
"Send invites": "Envoyer les invitations",
|
||||||
"Send scheduled for {when}": "Envoi programmé pour {when}",
|
"Send scheduled for {when}": "Envoi programmé pour {when}",
|
||||||
"Send without a subject?": "Envoyer sans objet ?",
|
"Send without a subject?": "Envoyer sans objet ?",
|
||||||
|
|||||||
@@ -642,6 +642,12 @@ export const catalog: Catalog = {
|
|||||||
"+{n} more": "他 {n} 件",
|
"+{n} more": "他 {n} 件",
|
||||||
|
|
||||||
"Contacts": "連絡先",
|
"Contacts": "連絡先",
|
||||||
|
|
||||||
|
"Collected": "収集したアドレス",
|
||||||
|
|
||||||
|
"Save people you write to as contacts": "メールを送った相手を連絡先に保存する",
|
||||||
|
|
||||||
|
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "まだ連絡先にない送信先は「収集したアドレス」アドレス帳に追加され、どのデバイスでも候補に表示されます。自分のアドレスは追加されません。",
|
||||||
"Contacts are not available": "連絡先は利用できません",
|
"Contacts are not available": "連絡先は利用できません",
|
||||||
"New contact": "新しい連絡先",
|
"New contact": "新しい連絡先",
|
||||||
"Edit contact": "連絡先を編集",
|
"Edit contact": "連絡先を編集",
|
||||||
@@ -727,6 +733,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────
|
||||||
"Settings": "設定",
|
"Settings": "設定",
|
||||||
|
"Show folder list": "フォルダー一覧を表示",
|
||||||
|
"Hide folder list": "フォルダー一覧を隠す",
|
||||||
"All settings": "すべての設定",
|
"All settings": "すべての設定",
|
||||||
"Sections": "セクション",
|
"Sections": "セクション",
|
||||||
"General": "一般",
|
"General": "一般",
|
||||||
@@ -808,6 +816,9 @@ export const catalog: Catalog = {
|
|||||||
"Text size": "文字サイズ",
|
"Text size": "文字サイズ",
|
||||||
"Font size": "フォントサイズ",
|
"Font size": "フォントサイズ",
|
||||||
"Small": "小",
|
"Small": "小",
|
||||||
|
"Original size": "元のサイズ",
|
||||||
|
"Drag to resize": "ドラッグしてサイズを変更",
|
||||||
|
"Image size": "画像のサイズ",
|
||||||
"Medium": "中",
|
"Medium": "中",
|
||||||
"Large": "大",
|
"Large": "大",
|
||||||
"Huge": "特大",
|
"Huge": "特大",
|
||||||
@@ -1333,6 +1344,9 @@ export const catalog: Catalog = {
|
|||||||
"Send anyway": "このまま送信",
|
"Send anyway": "このまま送信",
|
||||||
"Send canceled — the message is back in Drafts": "送信を取り消しました。メールは下書きに戻っています",
|
"Send canceled — the message is back in Drafts": "送信を取り消しました。メールは下書きに戻っています",
|
||||||
"Send failed: {error}": "送信できませんでした: {error}",
|
"Send failed: {error}": "送信できませんでした: {error}",
|
||||||
|
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "このメッセージが送信済みかどうかを確認できませんでした。もう一度送信する前に送信済みフォルダーを確認してください。",
|
||||||
|
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "このメッセージが送信されたかどうかを確認できませんでした。もう一度送信する前に送信済みフォルダーを確認してください。",
|
||||||
|
"This message had already been sent, so it wasn't sent again.": "このメッセージはすでに送信されていたため、再送信しませんでした。",
|
||||||
"Send invites": "招待を送信",
|
"Send invites": "招待を送信",
|
||||||
"Send scheduled for {when}": "{when} に送信を予約しました",
|
"Send scheduled for {when}": "{when} に送信を予約しました",
|
||||||
"Send without a subject?": "件名なしで送信しますか?",
|
"Send without a subject?": "件名なしで送信しますか?",
|
||||||
|
|||||||
@@ -643,6 +643,12 @@ export const catalog: Catalog = {
|
|||||||
"+{n} more": "+{n} meer",
|
"+{n} more": "+{n} meer",
|
||||||
|
|
||||||
"Contacts": "Contacten",
|
"Contacts": "Contacten",
|
||||||
|
|
||||||
|
"Collected": "Verzameld",
|
||||||
|
|
||||||
|
"Save people you write to as contacts": "Mensen aan wie je schrijft als contact opslaan",
|
||||||
|
|
||||||
|
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Iedereen aan wie je mailt en die nog geen contact is, wordt toegevoegd aan het adresboek Verzameld, zodat die op elk apparaat wordt voorgesteld. Je eigen adressen worden nooit toegevoegd.",
|
||||||
"Contacts are not available": "Contacten zijn niet beschikbaar",
|
"Contacts are not available": "Contacten zijn niet beschikbaar",
|
||||||
"New contact": "Nieuw contact",
|
"New contact": "Nieuw contact",
|
||||||
"Edit contact": "Contact bewerken",
|
"Edit contact": "Contact bewerken",
|
||||||
@@ -728,6 +734,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────
|
||||||
"Settings": "Instellingen",
|
"Settings": "Instellingen",
|
||||||
|
"Show folder list": "Mappenlijst tonen",
|
||||||
|
"Hide folder list": "Mappenlijst verbergen",
|
||||||
"All settings": "Alle instellingen",
|
"All settings": "Alle instellingen",
|
||||||
"Sections": "Onderdelen",
|
"Sections": "Onderdelen",
|
||||||
"General": "Algemeen",
|
"General": "Algemeen",
|
||||||
@@ -809,6 +817,9 @@ export const catalog: Catalog = {
|
|||||||
"Text size": "Tekstgrootte",
|
"Text size": "Tekstgrootte",
|
||||||
"Font size": "Lettergrootte",
|
"Font size": "Lettergrootte",
|
||||||
"Small": "Klein",
|
"Small": "Klein",
|
||||||
|
"Original size": "Oorspronkelijke grootte",
|
||||||
|
"Drag to resize": "Sleep om het formaat te wijzigen",
|
||||||
|
"Image size": "Afbeeldingsgrootte",
|
||||||
"Medium": "Middel",
|
"Medium": "Middel",
|
||||||
"Large": "Groot",
|
"Large": "Groot",
|
||||||
"Huge": "Zeer groot",
|
"Huge": "Zeer groot",
|
||||||
@@ -1325,6 +1336,9 @@ export const catalog: Catalog = {
|
|||||||
"Send anyway": "Toch verzenden",
|
"Send anyway": "Toch verzenden",
|
||||||
"Send canceled — the message is back in Drafts": "Verzenden geannuleerd — het bericht staat weer bij Concepten",
|
"Send canceled — the message is back in Drafts": "Verzenden geannuleerd — het bericht staat weer bij Concepten",
|
||||||
"Send failed: {error}": "Verzenden mislukt: {error}",
|
"Send failed: {error}": "Verzenden mislukt: {error}",
|
||||||
|
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Kon niet controleren of dit bericht al is verzonden. Kijk in Verzonden voordat je het opnieuw verstuurt.",
|
||||||
|
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Kon niet bevestigen of dit bericht is verzonden. Kijk in Verzonden voordat je het opnieuw verstuurt.",
|
||||||
|
"This message had already been sent, so it wasn't sent again.": "Dit bericht was al verzonden en is daarom niet opnieuw verstuurd.",
|
||||||
"Send invites": "Uitnodigingen verzenden",
|
"Send invites": "Uitnodigingen verzenden",
|
||||||
"Send scheduled for {when}": "Verzenden gepland voor {when}",
|
"Send scheduled for {when}": "Verzenden gepland voor {when}",
|
||||||
"Send without a subject?": "Verzenden zonder een onderwerp?",
|
"Send without a subject?": "Verzenden zonder een onderwerp?",
|
||||||
|
|||||||
@@ -0,0 +1,729 @@
|
|||||||
|
import type { PermissionCatalog } from "@/lib/permissionLabels";
|
||||||
|
|
||||||
|
/** Stalwart 0.16.22's permission labels in Turkish, keyed by permission name. Checked against source.json. */
|
||||||
|
export const permissionCatalog: PermissionCatalog = {
|
||||||
|
categories: {
|
||||||
|
"AI models Management": "Yapay Zeka Modelleri Yönetimi",
|
||||||
|
"API keys Management": "API Anahtarları Yönetimi",
|
||||||
|
"Accounts Management": "Hesap Yönetimi",
|
||||||
|
"Action": "Eylem",
|
||||||
|
"Actions Management": "Eylem Yönetimi",
|
||||||
|
"Addresses Management": "Adres Yönetimi",
|
||||||
|
"Alerts Management": "Uyarı Yönetimi",
|
||||||
|
"App passwords Management": "Uygulama Şifreleri Yönetimi",
|
||||||
|
"Applications Management": "Uygulama Yönetimi",
|
||||||
|
"Archived items Management": "Arşivlenmiş Ögeler Yönetimi",
|
||||||
|
"Calendar": "Takvim",
|
||||||
|
"Certificates Management": "Sertifika Yönetimi",
|
||||||
|
"Cluster nodes Management": "Küme Düğümleri Yönetimi",
|
||||||
|
"DNS servers Management": "DNS Sunucuları Yönetimi",
|
||||||
|
"Directories Management": "Dizin Yönetimi",
|
||||||
|
"Domains Management": "Alan Adı Yönetimi",
|
||||||
|
"Email": "E-posta",
|
||||||
|
"Events Management": "Etkinlik Yönetimi",
|
||||||
|
"Extensions Management": "Uzantı Yönetimi",
|
||||||
|
"Hooks Management": "Kanca (Hook) Yönetimi",
|
||||||
|
"IMAP": "IMAP",
|
||||||
|
"JMAP": "JMAP",
|
||||||
|
"Keys Management": "Anahtar Yönetimi",
|
||||||
|
"Listeners Management": "Dinleyici (Listener) Yönetimi",
|
||||||
|
"Lists Management": "Liste Yönetimi",
|
||||||
|
"Log entries Management": "Günlük Kayıtları Yönetimi",
|
||||||
|
"Lookups Management": "Arama (Lookup) Yönetimi",
|
||||||
|
"Mailing lists Management": "E-posta Listeleri Yönetimi",
|
||||||
|
"ManageSieve": "ManageSieve",
|
||||||
|
"Masked emails Management": "Maskelenmiş E-posta Yönetimi",
|
||||||
|
"Messages Management": "İleti Yönetimi",
|
||||||
|
"Metrics Management": "Metrik Yönetimi",
|
||||||
|
"Milters Management": "Milter Yönetimi",
|
||||||
|
"OAuth clients Management": "OAuth İstemcileri Yönetimi",
|
||||||
|
"POP3": "POP3",
|
||||||
|
"Providers Management": "Sağlayıcı Yönetimi",
|
||||||
|
"Public keys Management": "Genel Anahtar Yönetimi",
|
||||||
|
"Queues Management": "Kuyruk Yönetimi",
|
||||||
|
"Quotas Management": "Kota Yönetimi",
|
||||||
|
"Reports Management": "Rapor Yönetimi",
|
||||||
|
"Roles Management": "Rol Yönetimi",
|
||||||
|
"Routes Management": "Rota Yönetimi",
|
||||||
|
"Rules Management": "Kural Yönetimi",
|
||||||
|
"Samples Management": "Örnek Yönetimi",
|
||||||
|
"Schedules Management": "Zamanlama Yönetimi",
|
||||||
|
"Scripts Management": "Betik Yönetimi",
|
||||||
|
"Servers Management": "Sunucu Yönetimi",
|
||||||
|
"Settings": "Ayarlar",
|
||||||
|
"Signatures Management": "İmza Yönetimi",
|
||||||
|
"Strategies Management": "Strateji Yönetimi",
|
||||||
|
"Tags Management": "Etiket Yönetimi",
|
||||||
|
"Task": "Görev",
|
||||||
|
"Tasks Management": "Görev Yönetimi",
|
||||||
|
"Tenants Management": "Kiracı (Tenant) Yönetimi",
|
||||||
|
"Throttles Management": "Hız Sınırlama (Throttle) Yönetimi",
|
||||||
|
"Tracers Management": "İzleyici (Tracer) Yönetimi",
|
||||||
|
"Traces Management": "İzleme Kayıtları (Trace) Yönetimi",
|
||||||
|
"WebDAV": "WebDAV",
|
||||||
|
"Webhooks Management": "Webhook Yönetimi",
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
authenticate: "Sunucuya giriş yap",
|
||||||
|
authenticateWithAlias: "E-posta takma adları kullanarak giriş yap",
|
||||||
|
interactAi: "Yapay zeka modelleriyle etkileşim kur",
|
||||||
|
impersonate: "Başka bir kullanıcı adına işlem yap",
|
||||||
|
unlimitedRequests: "İstek hız sınırlarını atla",
|
||||||
|
unlimitedUploads: "Yükleme boyutu ve hız sınırlarını atla",
|
||||||
|
fetchAnyBlob: "Hesaba ait olmayanlar dahil herhangi bir blob nesnesini al",
|
||||||
|
emailSend: "E-postaları gönder",
|
||||||
|
emailReceive: "E-postaları al",
|
||||||
|
calendarAlarmsSend: "Takvim anımsatıcı bildirimlerini e-posta ile gönder",
|
||||||
|
calendarSchedulingSend: "Takvim davetlerini ve güncellemelerini e-posta ile gönder",
|
||||||
|
calendarSchedulingReceive: "Gelen takvim davetlerini ve yanıtlarını işle",
|
||||||
|
jmapPushSubscriptionGet: "Push aboneliğini ayrıntılarını al",
|
||||||
|
jmapPushSubscriptionCreate: "Yeni push aboneliklerini oluştur",
|
||||||
|
jmapPushSubscriptionUpdate: "Mevcut push aboneliklerini düzenle",
|
||||||
|
jmapPushSubscriptionDestroy: "Push aboneliklerini kaldır",
|
||||||
|
jmapMailboxGet: "Posta kutusunu ayrıntılarını ve özelliklerini al",
|
||||||
|
jmapMailboxChanges: "Belirli bir durumdan bu yana posta kutularını üzerindeki değişiklikleri izle",
|
||||||
|
jmapMailboxQuery: "Kriterlerle eşleşen posta kutularını ara",
|
||||||
|
jmapMailboxQueryChanges: "Posta kutusunu sorgu sonuçlarındaki değişiklikleri izle",
|
||||||
|
jmapMailboxCreate: "Yeni posta kutularını oluştur",
|
||||||
|
jmapMailboxUpdate: "Mevcut posta kutularını düzenle",
|
||||||
|
jmapMailboxDestroy: "Posta kutularını kaldır",
|
||||||
|
jmapThreadGet: "Email thread ayrıntılarını al",
|
||||||
|
jmapThreadChanges: "Belirli bir durumdan bu yana yazışmaları üzerindeki değişiklikleri izle",
|
||||||
|
jmapEmailGet: "E-postayı ayrıntılarını ve özelliklerini al",
|
||||||
|
jmapEmailChanges: "Belirli bir durumdan bu yana e-postaları üzerindeki değişiklikleri izle",
|
||||||
|
jmapEmailQuery: "Kriterlerle eşleşen e-postaları ara",
|
||||||
|
jmapEmailQueryChanges: "E-postayı sorgu sonuçlarındaki değişiklikleri izle",
|
||||||
|
jmapEmailCreate: "Yeni e-postaları oluştur",
|
||||||
|
jmapEmailUpdate: "Mevcut e-postaları düzenle",
|
||||||
|
jmapEmailDestroy: "E-postaları kaldır",
|
||||||
|
jmapEmailCopy: "E-postaları başka bir hesaba kopyala",
|
||||||
|
jmapEmailImport: "Blob verilerinden e-postaları içe aktar",
|
||||||
|
jmapEmailParse: "Ham e-posta verilerini kaydetmeden ayrıştır",
|
||||||
|
jmapSearchSnippetGet: "E-postalar için arama sonucu pasajlarını al",
|
||||||
|
jmapIdentityGet: "Gönderen kimliğini ayrıntılarını al",
|
||||||
|
jmapIdentityChanges: "Belirli bir durumdan bu yana kimlikleri üzerindeki değişiklikleri izle",
|
||||||
|
jmapIdentityCreate: "Yeni gönderen kimliklerini oluştur",
|
||||||
|
jmapIdentityUpdate: "Mevcut gönderen kimliklerini düzenle",
|
||||||
|
jmapIdentityDestroy: "Gönderen kimliklerini kaldır",
|
||||||
|
jmapEmailSubmissionGet: "E-posta gönderimini ayrıntılarını al",
|
||||||
|
jmapEmailSubmissionChanges: "Belirli bir durumdan bu yana e-posta gönderimlerini üzerindeki değişiklikleri izle",
|
||||||
|
jmapEmailSubmissionQuery: "Kriterlerle eşleşen e-posta gönderimlerini ara",
|
||||||
|
jmapEmailSubmissionQueryChanges: "E-posta gönderimini sorgu sonuçlarındaki değişiklikleri izle",
|
||||||
|
jmapEmailSubmissionCreate: "Yeni email submissions for sending oluştur",
|
||||||
|
jmapEmailSubmissionUpdate: "Mevcut e-posta gönderimlerini düzenle",
|
||||||
|
jmapEmailSubmissionDestroy: "E-posta gönderimlerini kaldır",
|
||||||
|
jmapVacationResponseGet: "Tatil yanıtı yapılandırmasını al",
|
||||||
|
jmapVacationResponseCreate: "Tatil yanıtı ayarlarını oluştur",
|
||||||
|
jmapVacationResponseUpdate: "Tatil / otomatik yanıt ayarlarını düzenle",
|
||||||
|
jmapVacationResponseDestroy: "Tatil yanıtı ayarlarını kaldır",
|
||||||
|
jmapSieveScriptGet: "Sieve filtre betiğini ayrıntılarını al",
|
||||||
|
jmapSieveScriptQuery: "Kriterlerle eşleşen Sieve betiklerini ara",
|
||||||
|
jmapSieveScriptValidate: "Bir Sieve betiğini kaydetmeden doğrula",
|
||||||
|
jmapSieveScriptCreate: "Yeni Sieve filtre betiklerini oluştur",
|
||||||
|
jmapSieveScriptUpdate: "Mevcut Sieve filtre betiklerini düzenle",
|
||||||
|
jmapSieveScriptDestroy: "Sieve filtre betiklerini kaldır",
|
||||||
|
jmapPrincipalGet: "Kullanıcıyı/grubu (principal) ayrıntılarını ve özelliklerini al",
|
||||||
|
jmapPrincipalQuery: "Kriterlerle eşleşen kullanıcıları/grupları (principal) ara",
|
||||||
|
jmapPrincipalChanges: "Belirli bir durumdan bu yana kullanıcıları/grupları (principal) üzerindeki değişiklikleri izle",
|
||||||
|
jmapPrincipalQueryChanges: "Kullanıcıyı/grubu (principal) sorgu sonuçlarındaki değişiklikleri izle",
|
||||||
|
jmapPrincipalGetAvailability: "Availability information for principals sorgula",
|
||||||
|
jmapPrincipalCreate: "Yeni kullanıcıları/grupları (principal) oluştur",
|
||||||
|
jmapPrincipalUpdate: "Mevcut kullanıcıları/grupları (principal) düzenle",
|
||||||
|
jmapPrincipalDestroy: "Kullanıcıları/grupları (principal) kaldır",
|
||||||
|
jmapQuotaGet: "Kota kullanımını ve sınırlarını al",
|
||||||
|
jmapQuotaChanges: "Belirli bir durumdan bu yana kotaları üzerindeki değişiklikleri izle",
|
||||||
|
jmapQuotaQuery: "Kriterlerle eşleşen kotaları ara",
|
||||||
|
jmapQuotaQueryChanges: "Kotayı sorgu sonuçlarındaki değişiklikleri izle",
|
||||||
|
jmapBlobGet: "Blob data indir",
|
||||||
|
jmapBlobCopy: "Blob nesnelerini başka bir hesaba kopyala",
|
||||||
|
jmapBlobLookup: "Veri türleri genelinde blob kullanımını sorgula",
|
||||||
|
jmapBlobUpload: "New blob data yükle",
|
||||||
|
jmapAddressBookGet: "Adres defterini ayrıntılarını ve özelliklerini al",
|
||||||
|
jmapAddressBookChanges: "Belirli bir durumdan bu yana adres defterlerini üzerindeki değişiklikleri izle",
|
||||||
|
jmapAddressBookCreate: "Yeni adres defterlerini oluştur",
|
||||||
|
jmapAddressBookUpdate: "Mevcut adres defterlerini düzenle",
|
||||||
|
jmapAddressBookDestroy: "Adres defterlerini kaldır",
|
||||||
|
jmapContactCardGet: "Kişi kartını ayrıntılarını al",
|
||||||
|
jmapContactCardChanges: "Belirli bir durumdan bu yana kişi kartlarını üzerindeki değişiklikleri izle",
|
||||||
|
jmapContactCardQuery: "Kriterlerle eşleşen kişi kartlarını ara",
|
||||||
|
jmapContactCardQueryChanges: "Kişi kartını sorgu sonuçlarındaki değişiklikleri izle",
|
||||||
|
jmapContactCardCreate: "Yeni kişi kartlarını oluştur",
|
||||||
|
jmapContactCardUpdate: "Mevcut kişi kartlarını düzenle",
|
||||||
|
jmapContactCardDestroy: "Kişi kartlarını kaldır",
|
||||||
|
jmapContactCardCopy: "Kişi kartlarını başka bir hesaba kopyala",
|
||||||
|
jmapContactCardParse: "Ham kişi kartı verilerini kaydetmeden ayrıştır",
|
||||||
|
jmapFileNodeGet: "Dosya düğümünü ayrıntılarını ve özelliklerini al",
|
||||||
|
jmapFileNodeChanges: "Belirli bir durumdan bu yana dosya düğümlerini üzerindeki değişiklikleri izle",
|
||||||
|
jmapFileNodeQuery: "Kriterlerle eşleşen dosya düğümlerini ara",
|
||||||
|
jmapFileNodeQueryChanges: "Dosya düğümünü sorgu sonuçlarındaki değişiklikleri izle",
|
||||||
|
jmapFileNodeCreate: "Yeni dosya düğümlerini oluştur",
|
||||||
|
jmapFileNodeUpdate: "Mevcut dosya düğümlerini düzenle",
|
||||||
|
jmapFileNodeDestroy: "Dosya düğümlerini kaldır",
|
||||||
|
jmapFileNodeCopy: "Dosya düğümlerini başka bir hesaba kopyala",
|
||||||
|
jmapShareNotificationGet: "Paylaşım bildirimini ayrıntılarını al",
|
||||||
|
jmapShareNotificationChanges: "Belirli bir durumdan bu yana paylaşım bildirimlerini üzerindeki değişiklikleri izle",
|
||||||
|
jmapShareNotificationQuery: "Kriterlerle eşleşen paylaşım bildirimlerini ara",
|
||||||
|
jmapShareNotificationQueryChanges: "Paylaşım bildirimini sorgu sonuçlarındaki değişiklikleri izle",
|
||||||
|
jmapShareNotificationCreate: "Yeni paylaşım bildirimlerini oluştur",
|
||||||
|
jmapShareNotificationUpdate: "Mevcut paylaşım bildirimlerini düzenle",
|
||||||
|
jmapShareNotificationDestroy: "Paylaşım bildirimlerini kaldır",
|
||||||
|
jmapCalendarGet: "Takvimi ayrıntılarını ve özelliklerini al",
|
||||||
|
jmapCalendarChanges: "Belirli bir durumdan bu yana takvimleri üzerindeki değişiklikleri izle",
|
||||||
|
jmapCalendarCreate: "Yeni takvimleri oluştur",
|
||||||
|
jmapCalendarUpdate: "Mevcut takvimleri düzenle",
|
||||||
|
jmapCalendarDestroy: "Takvimleri kaldır",
|
||||||
|
jmapCalendarEventGet: "Takvim etkinliğini ayrıntılarını al",
|
||||||
|
jmapCalendarEventChanges: "Belirli bir durumdan bu yana takvim etkinliklerini üzerindeki değişiklikleri izle",
|
||||||
|
jmapCalendarEventQuery: "Kriterlerle eşleşen takvim etkinliklerini ara",
|
||||||
|
jmapCalendarEventQueryChanges: "Takvim etkinliğini sorgu sonuçlarındaki değişiklikleri izle",
|
||||||
|
jmapCalendarEventCreate: "Yeni takvim etkinliklerini oluştur",
|
||||||
|
jmapCalendarEventUpdate: "Mevcut takvim etkinliklerini düzenle",
|
||||||
|
jmapCalendarEventDestroy: "Takvim etkinliklerini kaldır",
|
||||||
|
jmapCalendarEventCopy: "Takvim etkinliklerini başka bir hesaba kopyala",
|
||||||
|
jmapCalendarEventParse: "Ham takvim etkinliği verilerini kaydetmeden ayrıştır",
|
||||||
|
jmapCalendarEventNotificationGet: "Takvim etkinliği bildirimini ayrıntılarını al",
|
||||||
|
jmapCalendarEventNotificationChanges: "Belirli bir durumdan bu yana takvim etkinliği bildirimlerini üzerindeki değişiklikleri izle",
|
||||||
|
jmapCalendarEventNotificationQuery: "Kriterlerle eşleşen takvim etkinliği bildirimlerini ara",
|
||||||
|
jmapCalendarEventNotificationQueryChanges: "Takvim etkinliği bildirimini sorgu sonuçlarındaki değişiklikleri izle",
|
||||||
|
jmapCalendarEventNotificationCreate: "Yeni takvim etkinliği bildirimlerini oluştur",
|
||||||
|
jmapCalendarEventNotificationUpdate: "Mevcut takvim etkinliği bildirimlerini düzenle",
|
||||||
|
jmapCalendarEventNotificationDestroy: "Takvim etkinliği bildirimlerini kaldır",
|
||||||
|
jmapParticipantIdentityGet: "Participant identity ayrıntılarını al",
|
||||||
|
jmapParticipantIdentityChanges: "Belirli bir durumdan bu yana participant identities üzerindeki değişiklikleri izle",
|
||||||
|
jmapParticipantIdentityCreate: "Yeni participant identities oluştur",
|
||||||
|
jmapParticipantIdentityUpdate: "Mevcut participant identities düzenle",
|
||||||
|
jmapParticipantIdentityDestroy: "Participant identities kaldır",
|
||||||
|
jmapCoreEcho: "Test amacıyla giriş verisini geri yankıla (echo)",
|
||||||
|
imapAuthenticate: "IMAP sunucusuna giriş yap",
|
||||||
|
imapAclGet: "Posta kutusu erişim kontrol listelerini (ACL) görüntüle",
|
||||||
|
imapAclSet: "Posta kutusu erişim kontrol listelerini (ACL) düzenle",
|
||||||
|
imapMyRights: "Bir posta kutusundaki kendi yetkilerini görüntüle",
|
||||||
|
imapListRights: "Bir posta kutusu için kullanılabilir yetki bayraklarını listele",
|
||||||
|
imapAppend: "Messages to a mailbox yükle",
|
||||||
|
imapCapability: "Supported imap extensions sorgula",
|
||||||
|
imapId: "Server identification and version sorgula",
|
||||||
|
imapCopy: "Posta kutuları arasında iletileri kopyala",
|
||||||
|
imapMove: "Posta kutuları arasında iletileri taşı",
|
||||||
|
imapCreate: "Yeni posta kutularını oluştur",
|
||||||
|
imapDelete: "Mailboxes or mark messages for deletion sil",
|
||||||
|
imapEnable: "İsteğe bağlı IMAP uzantılarını etkinleştir",
|
||||||
|
imapExpunge: "Silinmek üzere işaretlenmiş iletileri kalıcı olarak kaldır (expunge)",
|
||||||
|
imapFetch: "Message content and metadata indir",
|
||||||
|
imapIdle: "Posta kutusu değişiklikleri için gerçek zamanlı bildirimler al (idle)",
|
||||||
|
imapList: "Kullanılabilir posta kutularını ve özniteliklerini listele",
|
||||||
|
imapLsub: "Kullanıcının abone olduğu posta kutularını listele",
|
||||||
|
imapNamespace: "Available mailbox namespace prefixes sorgula",
|
||||||
|
imapRename: "Posta kutularını yeniden adlandır veya taşı",
|
||||||
|
imapSearch: "Kriterlere göre iletileri ara",
|
||||||
|
imapSort: "İletileri belirli bir sıralama düzeninde al",
|
||||||
|
imapSelect: "Bir posta kutusunu okuma-yazma erişimi için aç",
|
||||||
|
imapExamine: "Bir posta kutusunu salt okunur erişim için aç",
|
||||||
|
imapStatus: "Mailbox message counts and state sorgula",
|
||||||
|
imapStore: "İleti bayraklarını ayarla veya temizle (okundu, yıldızlı, silindi vb.)",
|
||||||
|
imapSubscribe: "Posta kutularına abone ol veya abonelikten çık",
|
||||||
|
imapThread: "İletileri yazışma dizileri halinde grupla",
|
||||||
|
pop3Authenticate: "POP3 sunucusuna giriş yap",
|
||||||
|
pop3List: "İletileri ve boyutlarını listele",
|
||||||
|
pop3Uidl: "Benzersiz ileti tanımlayıcılarını al (UIDL)",
|
||||||
|
pop3Stat: "Mailbox message count and total size sorgula",
|
||||||
|
pop3Retr: "Message content indir",
|
||||||
|
pop3Dele: "Bağlantı kesildiğinde silinmek üzere iletileri işaretle",
|
||||||
|
sieveAuthenticate: "ManageSieve sunucusuna giriş yap",
|
||||||
|
sieveListScripts: "Yüklenen Sieve betiklerini listele",
|
||||||
|
sieveSetActive: "Hangi Sieve betiğinin aktif olduğunu belirle",
|
||||||
|
sieveGetScript: "Sieve script content indir",
|
||||||
|
sievePutScript: "Or replace a sieve script yükle",
|
||||||
|
sieveDeleteScript: "A sieve script kaldır",
|
||||||
|
sieveRenameScript: "Bir Sieve betiğini yeniden adlandır",
|
||||||
|
sieveCheckScript: "Sieve betiği sözdizimini kaydetmeden doğrula",
|
||||||
|
sieveHaveSpace: "Kotanın belirli boyuttaki bir betiği yüklemeye izin verip vermediğini denetle",
|
||||||
|
davSyncCollection: "Koleksiyon değişikliklerini istemciyle eşitle",
|
||||||
|
davExpandProperty: "Diğer kaynaklara başvuran özellikleri genişlet",
|
||||||
|
davPrincipalAcl: "Kullanıcılar/gruplar (principal) üzerindeki erişim kontrol özelliklerini yönet",
|
||||||
|
davPrincipalList: "Sistemdeki kullanılabilir kullanıcıları/grupları (principal) listele",
|
||||||
|
davPrincipalMatch: "Belirtilen kriterlere göre kullanıcıları/grupları (principal) eşleştir",
|
||||||
|
davPrincipalSearch: "Özellik değerlerine göre kullanıcıları/grupları (principal) ara",
|
||||||
|
davPrincipalSearchPropSet: "Which properties can be searched on principals sorgula",
|
||||||
|
davFilePropFind: "Dosya kaynaklarının özelliklerini al",
|
||||||
|
davFilePropPatch: "Dosya kaynaklarının özelliklerini düzenle",
|
||||||
|
davFileGet: "File resources indir",
|
||||||
|
davFileMkCol: "Yeni file collections or directories oluştur",
|
||||||
|
davFileDelete: "File resources kaldır",
|
||||||
|
davFilePut: "Or modify file resources yükle",
|
||||||
|
davFileCopy: "Dosya kaynaklarını yeni konumlara kopyala",
|
||||||
|
davFileMove: "Dosya kaynaklarını yeni konumlara taşı",
|
||||||
|
davFileLock: "Eşzamanlı değişiklikleri önlemek için dosya kaynaklarını kilitle",
|
||||||
|
davFileAcl: "Dosya kaynakları için erişim kontrol listelerini (ACL) yönet",
|
||||||
|
davCardPropFind: "Adres defteri kayıtlarının özelliklerini al",
|
||||||
|
davCardPropPatch: "Adres defteri kayıtlarının özelliklerini düzenle",
|
||||||
|
davCardGet: "Address book entries indir",
|
||||||
|
davCardMkCol: "Yeni address book collections oluştur",
|
||||||
|
davCardDelete: "Address book entries or collections kaldır",
|
||||||
|
davCardPut: "Or modify address book entries yükle",
|
||||||
|
davCardCopy: "Adres defteri kayıtlarını yeni konumlara kopyala",
|
||||||
|
davCardMove: "Adres defteri kayıtlarını yeni konumlara taşı",
|
||||||
|
davCardLock: "Eşzamanlı değişiklikleri önlemek için adres defteri kayıtlarını kilitle",
|
||||||
|
davCardAcl: "Adres defteri kayıtları için erişim kontrol listelerini (ACL) yönet",
|
||||||
|
davCardQuery: "Kriterlerle eşleşen address book entries ara",
|
||||||
|
davCardMultiGet: "Tek bir istekte birden çok adres defteri kaydı al",
|
||||||
|
davCalPropFind: "Takvim kayıtlarının özelliklerini al",
|
||||||
|
davCalPropPatch: "Takvim kayıtlarının özelliklerini düzenle",
|
||||||
|
davCalGet: "Calendar entries indir",
|
||||||
|
davCalMkCol: "Yeni calendar collections oluştur",
|
||||||
|
davCalDelete: "Calendar entries or collections kaldır",
|
||||||
|
davCalPut: "Or modify calendar entries yükle",
|
||||||
|
davCalCopy: "Takvim kayıtlarını yeni konumlara kopyala",
|
||||||
|
davCalMove: "Takvim kayıtlarını yeni konumlara taşı",
|
||||||
|
davCalLock: "Eşzamanlı değişiklikleri önlemek için takvim kayıtlarını kilitle",
|
||||||
|
davCalAcl: "Takvim kayıtları için erişim kontrol listelerini (ACL) yönet",
|
||||||
|
davCalQuery: "Kriterlerle eşleşen calendar entries ara",
|
||||||
|
davCalMultiGet: "Tek bir istekte birden çok takvim kaydı al",
|
||||||
|
davCalFreeBusyQuery: "Free/busy time information for scheduling sorgula",
|
||||||
|
oAuthClientRegistration: "Yeni OAuth istemci uygulamaları kaydet",
|
||||||
|
oAuthClientOverride: "OAuth istemci kayıt gereksinimlerini atla",
|
||||||
|
liveTracing: "Gerçek zamanlı sunucu etkinlik izlerini (trace) görüntüle",
|
||||||
|
liveMetrics: "Gerçek zamanlı sunucu performans metriklerini görüntüle",
|
||||||
|
liveDeliveryTest: "Teslimat yapılandırmasını doğrulamak için test e-postaları gönder",
|
||||||
|
scimAccess: "SCIM uç noktası üzerinden kullanıcı ve grup sağla (provision)",
|
||||||
|
sysAccountGet: "Hesapları getir",
|
||||||
|
sysAccountCreate: "Hesapları oluştur",
|
||||||
|
sysAccountUpdate: "Hesapları güncelle",
|
||||||
|
sysAccountDestroy: "Hesapları sil",
|
||||||
|
sysAccountQuery: "Hesapları sorgula",
|
||||||
|
sysAccountPasswordGet: "Account password settings getir",
|
||||||
|
sysAccountPasswordUpdate: "Account password settings güncelle",
|
||||||
|
sysAccountSettingsGet: "Account settings settings getir",
|
||||||
|
sysAccountSettingsUpdate: "Account settings settings güncelle",
|
||||||
|
sysAcmeProviderGet: "Sağlayıcıları getir",
|
||||||
|
sysAcmeProviderCreate: "Sağlayıcıları oluştur",
|
||||||
|
sysAcmeProviderUpdate: "Sağlayıcıları güncelle",
|
||||||
|
sysAcmeProviderDestroy: "Sağlayıcıları sil",
|
||||||
|
sysAcmeProviderQuery: "Sağlayıcıları sorgula",
|
||||||
|
actionReloadSettings: "Yeniden yükle: Sunucu ayarları",
|
||||||
|
actionReloadTlsCertificates: "Yeniden yükle: TLS sertifikaları",
|
||||||
|
actionReloadLookupStores: "Yeniden yükle: Arama (lookup) depoları",
|
||||||
|
actionReloadBlockedIps: "Yeniden yükle: Engellenen IP listesi",
|
||||||
|
actionUpdateApps: "Uygulama Yönetimi: Uygulamaları güncelle",
|
||||||
|
actionTroubleshootDmarc: "DMARC: Sorun giderme",
|
||||||
|
actionClassifySpam: "Spam Filtresi: Bir iletiyi sınıflandır",
|
||||||
|
actionInvalidateCaches: "Önbellek: Tüm önbellekleri geçersiz kıl",
|
||||||
|
actionInvalidateNegativeCaches: "Önbellek: Negatif önbellekleri geçersiz kıl",
|
||||||
|
actionPauseMtaQueue: "MTA: Kuyruk işlemeyi duraklat",
|
||||||
|
actionResumeMtaQueue: "MTA: Kuyruk işlemeyi sürdür",
|
||||||
|
sysActionGet: "Eylemleri getir",
|
||||||
|
sysActionCreate: "Eylemleri oluştur",
|
||||||
|
sysActionUpdate: "Eylemleri güncelle",
|
||||||
|
sysActionDestroy: "Eylemleri sil",
|
||||||
|
sysActionQuery: "Eylemleri sorgula",
|
||||||
|
sysAddressBookGet: "Address book settings getir",
|
||||||
|
sysAddressBookUpdate: "Address book settings güncelle",
|
||||||
|
sysAiModelGet: "Yapay zeka modellerini getir",
|
||||||
|
sysAiModelCreate: "Yapay zeka modellerini oluştur",
|
||||||
|
sysAiModelUpdate: "Yapay zeka modellerini güncelle",
|
||||||
|
sysAiModelDestroy: "Yapay zeka modellerini sil",
|
||||||
|
sysAiModelQuery: "Yapay zeka modellerini sorgula",
|
||||||
|
sysAlertGet: "Uyarıları getir",
|
||||||
|
sysAlertCreate: "Uyarıları oluştur",
|
||||||
|
sysAlertUpdate: "Uyarıları güncelle",
|
||||||
|
sysAlertDestroy: "Uyarıları sil",
|
||||||
|
sysAlertQuery: "Uyarıları sorgula",
|
||||||
|
sysAllowedIpGet: "Adresleri getir",
|
||||||
|
sysAllowedIpCreate: "Adresleri oluştur",
|
||||||
|
sysAllowedIpUpdate: "Adresleri güncelle",
|
||||||
|
sysAllowedIpDestroy: "Adresleri sil",
|
||||||
|
sysAllowedIpQuery: "Adresleri sorgula",
|
||||||
|
sysApiKeyGet: "Api anahtarlarını getir",
|
||||||
|
sysApiKeyCreate: "Api anahtarlarını oluştur",
|
||||||
|
sysApiKeyUpdate: "Api anahtarlarını güncelle",
|
||||||
|
sysApiKeyDestroy: "Api anahtarlarını sil",
|
||||||
|
sysApiKeyQuery: "Api anahtarlarını sorgula",
|
||||||
|
sysAppPasswordGet: "Uygulama şifrelerini getir",
|
||||||
|
sysAppPasswordCreate: "Uygulama şifrelerini oluştur",
|
||||||
|
sysAppPasswordUpdate: "Uygulama şifrelerini güncelle",
|
||||||
|
sysAppPasswordDestroy: "Uygulama şifrelerini sil",
|
||||||
|
sysAppPasswordQuery: "Uygulama şifrelerini sorgula",
|
||||||
|
sysApplicationGet: "Uygulamaları getir",
|
||||||
|
sysApplicationCreate: "Uygulamaları oluştur",
|
||||||
|
sysApplicationUpdate: "Uygulamaları güncelle",
|
||||||
|
sysApplicationDestroy: "Uygulamaları sil",
|
||||||
|
sysApplicationQuery: "Uygulamaları sorgula",
|
||||||
|
sysArchivedItemGet: "Arşivlenmiş ögeleri getir",
|
||||||
|
sysArchivedItemCreate: "Arşivlenmiş ögeleri oluştur",
|
||||||
|
sysArchivedItemUpdate: "Arşivlenmiş ögeleri güncelle",
|
||||||
|
sysArchivedItemDestroy: "Arşivlenmiş ögeleri sil",
|
||||||
|
sysArchivedItemQuery: "Arşivlenmiş ögeleri sorgula",
|
||||||
|
sysArfExternalReportGet: "Raporları getir",
|
||||||
|
sysArfExternalReportCreate: "Raporları oluştur",
|
||||||
|
sysArfExternalReportUpdate: "Raporları güncelle",
|
||||||
|
sysArfExternalReportDestroy: "Raporları sil",
|
||||||
|
sysArfExternalReportQuery: "Raporları sorgula",
|
||||||
|
sysAsnGet: "Asn settings getir",
|
||||||
|
sysAsnUpdate: "Asn settings güncelle",
|
||||||
|
sysAuthenticationGet: "Authentication settings getir",
|
||||||
|
sysAuthenticationUpdate: "Authentication settings güncelle",
|
||||||
|
sysBlobStoreGet: "Blob store settings getir",
|
||||||
|
sysBlobStoreUpdate: "Blob store settings güncelle",
|
||||||
|
sysBlockedIpGet: "Adresleri getir",
|
||||||
|
sysBlockedIpCreate: "Adresleri oluştur",
|
||||||
|
sysBlockedIpUpdate: "Adresleri güncelle",
|
||||||
|
sysBlockedIpDestroy: "Adresleri sil",
|
||||||
|
sysBlockedIpQuery: "Adresleri sorgula",
|
||||||
|
sysBootstrapGet: "Bootstrap settings getir",
|
||||||
|
sysBootstrapUpdate: "Bootstrap settings güncelle",
|
||||||
|
sysCacheGet: "Cache settings getir",
|
||||||
|
sysCacheUpdate: "Cache settings güncelle",
|
||||||
|
sysCalendarGet: "Calendar settings getir",
|
||||||
|
sysCalendarUpdate: "Calendar settings güncelle",
|
||||||
|
sysCalendarAlarmGet: "Calendar alarm settings getir",
|
||||||
|
sysCalendarAlarmUpdate: "Calendar alarm settings güncelle",
|
||||||
|
sysCalendarSchedulingGet: "Calendar scheduling settings getir",
|
||||||
|
sysCalendarSchedulingUpdate: "Calendar scheduling settings güncelle",
|
||||||
|
sysCertificateGet: "Sertifikaları getir",
|
||||||
|
sysCertificateCreate: "Sertifikaları oluştur",
|
||||||
|
sysCertificateUpdate: "Sertifikaları güncelle",
|
||||||
|
sysCertificateDestroy: "Sertifikaları sil",
|
||||||
|
sysCertificateQuery: "Sertifikaları sorgula",
|
||||||
|
sysClusterNodeGet: "Küme düğümlerini getir",
|
||||||
|
sysClusterNodeCreate: "Küme düğümlerini oluştur",
|
||||||
|
sysClusterNodeUpdate: "Küme düğümlerini güncelle",
|
||||||
|
sysClusterNodeDestroy: "Küme düğümlerini sil",
|
||||||
|
sysClusterNodeQuery: "Küme düğümlerini sorgula",
|
||||||
|
sysClusterRoleGet: "Rolleri getir",
|
||||||
|
sysClusterRoleCreate: "Rolleri oluştur",
|
||||||
|
sysClusterRoleUpdate: "Rolleri güncelle",
|
||||||
|
sysClusterRoleDestroy: "Rolleri sil",
|
||||||
|
sysClusterRoleQuery: "Rolleri sorgula",
|
||||||
|
sysCoordinatorGet: "Coordinator settings getir",
|
||||||
|
sysCoordinatorUpdate: "Coordinator settings güncelle",
|
||||||
|
sysDataRetentionGet: "Data retention settings getir",
|
||||||
|
sysDataRetentionUpdate: "Data retention settings güncelle",
|
||||||
|
sysDataStoreGet: "Data store settings getir",
|
||||||
|
sysDataStoreUpdate: "Data store settings güncelle",
|
||||||
|
sysDirectoryGet: "Dizinleri getir",
|
||||||
|
sysDirectoryCreate: "Dizinleri oluştur",
|
||||||
|
sysDirectoryUpdate: "Dizinleri güncelle",
|
||||||
|
sysDirectoryDestroy: "Dizinleri sil",
|
||||||
|
sysDirectoryQuery: "Dizinleri sorgula",
|
||||||
|
sysDkimReportSettingsGet: "Dkim report settings settings getir",
|
||||||
|
sysDkimReportSettingsUpdate: "Dkim report settings settings güncelle",
|
||||||
|
sysDkimSignatureGet: "Imzaları getir",
|
||||||
|
sysDkimSignatureCreate: "Imzaları oluştur",
|
||||||
|
sysDkimSignatureUpdate: "Imzaları güncelle",
|
||||||
|
sysDkimSignatureDestroy: "Imzaları sil",
|
||||||
|
sysDkimSignatureQuery: "Imzaları sorgula",
|
||||||
|
sysDmarcExternalReportGet: "Raporları getir",
|
||||||
|
sysDmarcExternalReportCreate: "Raporları oluştur",
|
||||||
|
sysDmarcExternalReportUpdate: "Raporları güncelle",
|
||||||
|
sysDmarcExternalReportDestroy: "Raporları sil",
|
||||||
|
sysDmarcExternalReportQuery: "Raporları sorgula",
|
||||||
|
sysDmarcInternalReportGet: "Raporları getir",
|
||||||
|
sysDmarcInternalReportCreate: "Raporları oluştur",
|
||||||
|
sysDmarcInternalReportUpdate: "Raporları güncelle",
|
||||||
|
sysDmarcInternalReportDestroy: "Raporları sil",
|
||||||
|
sysDmarcInternalReportQuery: "Raporları sorgula",
|
||||||
|
sysDmarcReportSettingsGet: "Dmarc report settings settings getir",
|
||||||
|
sysDmarcReportSettingsUpdate: "Dmarc report settings settings güncelle",
|
||||||
|
sysDnsResolverGet: "Dns resolver settings getir",
|
||||||
|
sysDnsResolverUpdate: "Dns resolver settings güncelle",
|
||||||
|
sysDnsServerGet: "Dns sunucularını getir",
|
||||||
|
sysDnsServerCreate: "Dns sunucularını oluştur",
|
||||||
|
sysDnsServerUpdate: "Dns sunucularını güncelle",
|
||||||
|
sysDnsServerDestroy: "Dns sunucularını sil",
|
||||||
|
sysDnsServerQuery: "Dns sunucularını sorgula",
|
||||||
|
sysDomainGet: "Alan adlarını getir",
|
||||||
|
sysDomainCreate: "Alan adlarını oluştur",
|
||||||
|
sysDomainUpdate: "Alan adlarını güncelle",
|
||||||
|
sysDomainDestroy: "Alan adlarını sil",
|
||||||
|
sysDomainQuery: "Alan adlarını sorgula",
|
||||||
|
sysDsnReportSettingsGet: "Dsn report settings settings getir",
|
||||||
|
sysDsnReportSettingsUpdate: "Dsn report settings settings güncelle",
|
||||||
|
sysEmailGet: "Email settings getir",
|
||||||
|
sysEmailUpdate: "Email settings güncelle",
|
||||||
|
sysEnterpriseGet: "Enterprise settings getir",
|
||||||
|
sysEnterpriseUpdate: "Enterprise settings güncelle",
|
||||||
|
sysEventTracingLevelGet: "Etkinlikleri getir",
|
||||||
|
sysEventTracingLevelCreate: "Etkinlikleri oluştur",
|
||||||
|
sysEventTracingLevelUpdate: "Etkinlikleri güncelle",
|
||||||
|
sysEventTracingLevelDestroy: "Etkinlikleri sil",
|
||||||
|
sysEventTracingLevelQuery: "Etkinlikleri sorgula",
|
||||||
|
sysFileStorageGet: "File storage settings getir",
|
||||||
|
sysFileStorageUpdate: "File storage settings güncelle",
|
||||||
|
sysHttpGet: "Http settings getir",
|
||||||
|
sysHttpUpdate: "Http settings güncelle",
|
||||||
|
sysHttpFormGet: "Http form settings getir",
|
||||||
|
sysHttpFormUpdate: "Http form settings güncelle",
|
||||||
|
sysHttpLookupGet: "Listeleri getir",
|
||||||
|
sysHttpLookupCreate: "Listeleri oluştur",
|
||||||
|
sysHttpLookupUpdate: "Listeleri güncelle",
|
||||||
|
sysHttpLookupDestroy: "Listeleri sil",
|
||||||
|
sysHttpLookupQuery: "Listeleri sorgula",
|
||||||
|
sysImapGet: "Imap settings getir",
|
||||||
|
sysImapUpdate: "Imap settings güncelle",
|
||||||
|
sysInMemoryStoreGet: "In memory store settings getir",
|
||||||
|
sysInMemoryStoreUpdate: "In memory store settings güncelle",
|
||||||
|
sysJmapGet: "Jmap settings getir",
|
||||||
|
sysJmapUpdate: "Jmap settings güncelle",
|
||||||
|
sysLogGet: "Günlük kayıtlarını getir",
|
||||||
|
sysLogCreate: "Günlük kayıtlarını oluştur",
|
||||||
|
sysLogUpdate: "Günlük kayıtlarını güncelle",
|
||||||
|
sysLogDestroy: "Günlük kayıtlarını sil",
|
||||||
|
sysLogQuery: "Günlük kayıtlarını sorgula",
|
||||||
|
sysMailingListGet: "E-posta listelerini getir",
|
||||||
|
sysMailingListCreate: "E-posta listelerini oluştur",
|
||||||
|
sysMailingListUpdate: "E-posta listelerini güncelle",
|
||||||
|
sysMailingListDestroy: "E-posta listelerini sil",
|
||||||
|
sysMailingListQuery: "E-posta listelerini sorgula",
|
||||||
|
sysMaskedEmailGet: "Maskelenmiş e-postaları getir",
|
||||||
|
sysMaskedEmailCreate: "Maskelenmiş e-postaları oluştur",
|
||||||
|
sysMaskedEmailUpdate: "Maskelenmiş e-postaları güncelle",
|
||||||
|
sysMaskedEmailDestroy: "Maskelenmiş e-postaları sil",
|
||||||
|
sysMaskedEmailQuery: "Maskelenmiş e-postaları sorgula",
|
||||||
|
sysMemoryLookupKeyGet: "Anahtarları getir",
|
||||||
|
sysMemoryLookupKeyCreate: "Anahtarları oluştur",
|
||||||
|
sysMemoryLookupKeyUpdate: "Anahtarları güncelle",
|
||||||
|
sysMemoryLookupKeyDestroy: "Anahtarları sil",
|
||||||
|
sysMemoryLookupKeyQuery: "Anahtarları sorgula",
|
||||||
|
sysMemoryLookupKeyValueGet: "Anahtarları getir",
|
||||||
|
sysMemoryLookupKeyValueCreate: "Anahtarları oluştur",
|
||||||
|
sysMemoryLookupKeyValueUpdate: "Anahtarları güncelle",
|
||||||
|
sysMemoryLookupKeyValueDestroy: "Anahtarları sil",
|
||||||
|
sysMemoryLookupKeyValueQuery: "Anahtarları sorgula",
|
||||||
|
sysMetricGet: "Metrikleri getir",
|
||||||
|
sysMetricCreate: "Metrikleri oluştur",
|
||||||
|
sysMetricUpdate: "Metrikleri güncelle",
|
||||||
|
sysMetricDestroy: "Metrikleri sil",
|
||||||
|
sysMetricQuery: "Metrikleri sorgula",
|
||||||
|
sysMetricsGet: "Metrics settings getir",
|
||||||
|
sysMetricsUpdate: "Metrics settings güncelle",
|
||||||
|
sysMetricsStoreGet: "Metrics store settings getir",
|
||||||
|
sysMetricsStoreUpdate: "Metrics store settings güncelle",
|
||||||
|
sysMtaConnectionStrategyGet: "Stratejileri getir",
|
||||||
|
sysMtaConnectionStrategyCreate: "Stratejileri oluştur",
|
||||||
|
sysMtaConnectionStrategyUpdate: "Stratejileri güncelle",
|
||||||
|
sysMtaConnectionStrategyDestroy: "Stratejileri sil",
|
||||||
|
sysMtaConnectionStrategyQuery: "Stratejileri sorgula",
|
||||||
|
sysMtaDeliveryScheduleGet: "Zamanlamaları getir",
|
||||||
|
sysMtaDeliveryScheduleCreate: "Zamanlamaları oluştur",
|
||||||
|
sysMtaDeliveryScheduleUpdate: "Zamanlamaları güncelle",
|
||||||
|
sysMtaDeliveryScheduleDestroy: "Zamanlamaları sil",
|
||||||
|
sysMtaDeliveryScheduleQuery: "Zamanlamaları sorgula",
|
||||||
|
sysMtaExtensionsGet: "Mta extensions settings getir",
|
||||||
|
sysMtaExtensionsUpdate: "Mta extensions settings güncelle",
|
||||||
|
sysMtaHookGet: "Kancaları (hook) getir",
|
||||||
|
sysMtaHookCreate: "Kancaları (hook) oluştur",
|
||||||
|
sysMtaHookUpdate: "Kancaları (hook) güncelle",
|
||||||
|
sysMtaHookDestroy: "Kancaları (hook) sil",
|
||||||
|
sysMtaHookQuery: "Kancaları (hook) sorgula",
|
||||||
|
sysMtaInboundSessionGet: "Mta inbound session settings getir",
|
||||||
|
sysMtaInboundSessionUpdate: "Mta inbound session settings güncelle",
|
||||||
|
sysMtaInboundThrottleGet: "Hız sınırlamalarını (throttle) getir",
|
||||||
|
sysMtaInboundThrottleCreate: "Hız sınırlamalarını (throttle) oluştur",
|
||||||
|
sysMtaInboundThrottleUpdate: "Hız sınırlamalarını (throttle) güncelle",
|
||||||
|
sysMtaInboundThrottleDestroy: "Hız sınırlamalarını (throttle) sil",
|
||||||
|
sysMtaInboundThrottleQuery: "Hız sınırlamalarını (throttle) sorgula",
|
||||||
|
sysMtaMilterGet: "Milter'ları getir",
|
||||||
|
sysMtaMilterCreate: "Milter'ları oluştur",
|
||||||
|
sysMtaMilterUpdate: "Milter'ları güncelle",
|
||||||
|
sysMtaMilterDestroy: "Milter'ları sil",
|
||||||
|
sysMtaMilterQuery: "Milter'ları sorgula",
|
||||||
|
sysMtaOutboundStrategyGet: "Mta outbound strategy settings getir",
|
||||||
|
sysMtaOutboundStrategyUpdate: "Mta outbound strategy settings güncelle",
|
||||||
|
sysMtaOutboundThrottleGet: "Hız sınırlamalarını (throttle) getir",
|
||||||
|
sysMtaOutboundThrottleCreate: "Hız sınırlamalarını (throttle) oluştur",
|
||||||
|
sysMtaOutboundThrottleUpdate: "Hız sınırlamalarını (throttle) güncelle",
|
||||||
|
sysMtaOutboundThrottleDestroy: "Hız sınırlamalarını (throttle) sil",
|
||||||
|
sysMtaOutboundThrottleQuery: "Hız sınırlamalarını (throttle) sorgula",
|
||||||
|
sysMtaQueueQuotaGet: "Kotaları getir",
|
||||||
|
sysMtaQueueQuotaCreate: "Kotaları oluştur",
|
||||||
|
sysMtaQueueQuotaUpdate: "Kotaları güncelle",
|
||||||
|
sysMtaQueueQuotaDestroy: "Kotaları sil",
|
||||||
|
sysMtaQueueQuotaQuery: "Kotaları sorgula",
|
||||||
|
sysMtaRouteGet: "Rotaları getir",
|
||||||
|
sysMtaRouteCreate: "Rotaları oluştur",
|
||||||
|
sysMtaRouteUpdate: "Rotaları güncelle",
|
||||||
|
sysMtaRouteDestroy: "Rotaları sil",
|
||||||
|
sysMtaRouteQuery: "Rotaları sorgula",
|
||||||
|
sysMtaStageAuthGet: "Mta stage auth settings getir",
|
||||||
|
sysMtaStageAuthUpdate: "Mta stage auth settings güncelle",
|
||||||
|
sysMtaStageConnectGet: "Mta stage connect settings getir",
|
||||||
|
sysMtaStageConnectUpdate: "Mta stage connect settings güncelle",
|
||||||
|
sysMtaStageDataGet: "Mta stage data settings getir",
|
||||||
|
sysMtaStageDataUpdate: "Mta stage data settings güncelle",
|
||||||
|
sysMtaStageEhloGet: "Mta stage ehlo settings getir",
|
||||||
|
sysMtaStageEhloUpdate: "Mta stage ehlo settings güncelle",
|
||||||
|
sysMtaStageMailGet: "Mta stage mail settings getir",
|
||||||
|
sysMtaStageMailUpdate: "Mta stage mail settings güncelle",
|
||||||
|
sysMtaStageRcptGet: "Mta stage rcpt settings getir",
|
||||||
|
sysMtaStageRcptUpdate: "Mta stage rcpt settings güncelle",
|
||||||
|
sysMtaStsGet: "Mta sts settings getir",
|
||||||
|
sysMtaStsUpdate: "Mta sts settings güncelle",
|
||||||
|
sysMtaTlsStrategyGet: "Stratejileri getir",
|
||||||
|
sysMtaTlsStrategyCreate: "Stratejileri oluştur",
|
||||||
|
sysMtaTlsStrategyUpdate: "Stratejileri güncelle",
|
||||||
|
sysMtaTlsStrategyDestroy: "Stratejileri sil",
|
||||||
|
sysMtaTlsStrategyQuery: "Stratejileri sorgula",
|
||||||
|
sysMtaVirtualQueueGet: "Kuyrukları getir",
|
||||||
|
sysMtaVirtualQueueCreate: "Kuyrukları oluştur",
|
||||||
|
sysMtaVirtualQueueUpdate: "Kuyrukları güncelle",
|
||||||
|
sysMtaVirtualQueueDestroy: "Kuyrukları sil",
|
||||||
|
sysMtaVirtualQueueQuery: "Kuyrukları sorgula",
|
||||||
|
sysNetworkListenerGet: "Dinleyicileri (listener) getir",
|
||||||
|
sysNetworkListenerCreate: "Dinleyicileri (listener) oluştur",
|
||||||
|
sysNetworkListenerUpdate: "Dinleyicileri (listener) güncelle",
|
||||||
|
sysNetworkListenerDestroy: "Dinleyicileri (listener) sil",
|
||||||
|
sysNetworkListenerQuery: "Dinleyicileri (listener) sorgula",
|
||||||
|
sysOAuthClientGet: "Oauth istemcilerini getir",
|
||||||
|
sysOAuthClientCreate: "Oauth istemcilerini oluştur",
|
||||||
|
sysOAuthClientUpdate: "Oauth istemcilerini güncelle",
|
||||||
|
sysOAuthClientDestroy: "Oauth istemcilerini sil",
|
||||||
|
sysOAuthClientQuery: "Oauth istemcilerini sorgula",
|
||||||
|
sysOidcProviderGet: "Oidc provider settings getir",
|
||||||
|
sysOidcProviderUpdate: "Oidc provider settings güncelle",
|
||||||
|
sysPublicKeyGet: "Genel anahtarları getir",
|
||||||
|
sysPublicKeyCreate: "Genel anahtarları oluştur",
|
||||||
|
sysPublicKeyUpdate: "Genel anahtarları güncelle",
|
||||||
|
sysPublicKeyDestroy: "Genel anahtarları sil",
|
||||||
|
sysPublicKeyQuery: "Genel anahtarları sorgula",
|
||||||
|
sysQueuedMessageGet: "Iletileri getir",
|
||||||
|
sysQueuedMessageCreate: "Iletileri oluştur",
|
||||||
|
sysQueuedMessageUpdate: "Iletileri güncelle",
|
||||||
|
sysQueuedMessageDestroy: "Iletileri sil",
|
||||||
|
sysQueuedMessageQuery: "Iletileri sorgula",
|
||||||
|
sysReportSettingsGet: "Report settings settings getir",
|
||||||
|
sysReportSettingsUpdate: "Report settings settings güncelle",
|
||||||
|
sysRoleGet: "Rolleri getir",
|
||||||
|
sysRoleCreate: "Rolleri oluştur",
|
||||||
|
sysRoleUpdate: "Rolleri güncelle",
|
||||||
|
sysRoleDestroy: "Rolleri sil",
|
||||||
|
sysRoleQuery: "Rolleri sorgula",
|
||||||
|
sysSearchGet: "Search settings getir",
|
||||||
|
sysSearchUpdate: "Search settings güncelle",
|
||||||
|
sysSearchStoreGet: "Search store settings getir",
|
||||||
|
sysSearchStoreUpdate: "Search store settings güncelle",
|
||||||
|
sysSecurityGet: "Security settings getir",
|
||||||
|
sysSecurityUpdate: "Security settings güncelle",
|
||||||
|
sysSenderAuthGet: "Sender auth settings getir",
|
||||||
|
sysSenderAuthUpdate: "Sender auth settings güncelle",
|
||||||
|
sysSharingGet: "Sharing settings getir",
|
||||||
|
sysSharingUpdate: "Sharing settings güncelle",
|
||||||
|
sysSieveSystemInterpreterGet: "Sieve system interpreter settings getir",
|
||||||
|
sysSieveSystemInterpreterUpdate: "Sieve system interpreter settings güncelle",
|
||||||
|
sysSieveSystemScriptGet: "Betikleri getir",
|
||||||
|
sysSieveSystemScriptCreate: "Betikleri oluştur",
|
||||||
|
sysSieveSystemScriptUpdate: "Betikleri güncelle",
|
||||||
|
sysSieveSystemScriptDestroy: "Betikleri sil",
|
||||||
|
sysSieveSystemScriptQuery: "Betikleri sorgula",
|
||||||
|
sysSieveUserInterpreterGet: "Sieve user interpreter settings getir",
|
||||||
|
sysSieveUserInterpreterUpdate: "Sieve user interpreter settings güncelle",
|
||||||
|
sysSieveUserScriptGet: "Betikleri getir",
|
||||||
|
sysSieveUserScriptCreate: "Betikleri oluştur",
|
||||||
|
sysSieveUserScriptUpdate: "Betikleri güncelle",
|
||||||
|
sysSieveUserScriptDestroy: "Betikleri sil",
|
||||||
|
sysSieveUserScriptQuery: "Betikleri sorgula",
|
||||||
|
sysSpamClassifierGet: "Spam classifier settings getir",
|
||||||
|
sysSpamClassifierUpdate: "Spam classifier settings güncelle",
|
||||||
|
sysSpamDnsblServerGet: "Sunucuları getir",
|
||||||
|
sysSpamDnsblServerCreate: "Sunucuları oluştur",
|
||||||
|
sysSpamDnsblServerUpdate: "Sunucuları güncelle",
|
||||||
|
sysSpamDnsblServerDestroy: "Sunucuları sil",
|
||||||
|
sysSpamDnsblServerQuery: "Sunucuları sorgula",
|
||||||
|
sysSpamDnsblSettingsGet: "Spam dnsbl settings settings getir",
|
||||||
|
sysSpamDnsblSettingsUpdate: "Spam dnsbl settings settings güncelle",
|
||||||
|
sysSpamFileExtensionGet: "Uzantıları getir",
|
||||||
|
sysSpamFileExtensionCreate: "Uzantıları oluştur",
|
||||||
|
sysSpamFileExtensionUpdate: "Uzantıları güncelle",
|
||||||
|
sysSpamFileExtensionDestroy: "Uzantıları sil",
|
||||||
|
sysSpamFileExtensionQuery: "Uzantıları sorgula",
|
||||||
|
sysSpamLlmGet: "Spam llm settings getir",
|
||||||
|
sysSpamLlmUpdate: "Spam llm settings güncelle",
|
||||||
|
sysSpamPyzorGet: "Spam pyzor settings getir",
|
||||||
|
sysSpamPyzorUpdate: "Spam pyzor settings güncelle",
|
||||||
|
sysSpamRuleGet: "Kuralları getir",
|
||||||
|
sysSpamRuleCreate: "Kuralları oluştur",
|
||||||
|
sysSpamRuleUpdate: "Kuralları güncelle",
|
||||||
|
sysSpamRuleDestroy: "Kuralları sil",
|
||||||
|
sysSpamRuleQuery: "Kuralları sorgula",
|
||||||
|
sysSpamSettingsGet: "Spam settings settings getir",
|
||||||
|
sysSpamSettingsUpdate: "Spam settings settings güncelle",
|
||||||
|
sysSpamTagGet: "Etiketleri getir",
|
||||||
|
sysSpamTagCreate: "Etiketleri oluştur",
|
||||||
|
sysSpamTagUpdate: "Etiketleri güncelle",
|
||||||
|
sysSpamTagDestroy: "Etiketleri sil",
|
||||||
|
sysSpamTagQuery: "Etiketleri sorgula",
|
||||||
|
sysSpamTrainingSampleGet: "Örnekleri getir",
|
||||||
|
sysSpamTrainingSampleCreate: "Örnekleri oluştur",
|
||||||
|
sysSpamTrainingSampleUpdate: "Örnekleri güncelle",
|
||||||
|
sysSpamTrainingSampleDestroy: "Örnekleri sil",
|
||||||
|
sysSpamTrainingSampleQuery: "Örnekleri sorgula",
|
||||||
|
sysSpfReportSettingsGet: "Spf report settings settings getir",
|
||||||
|
sysSpfReportSettingsUpdate: "Spf report settings settings güncelle",
|
||||||
|
sysStoreLookupGet: "Aramaları (lookup) getir",
|
||||||
|
sysStoreLookupCreate: "Aramaları (lookup) oluştur",
|
||||||
|
sysStoreLookupUpdate: "Aramaları (lookup) güncelle",
|
||||||
|
sysStoreLookupDestroy: "Aramaları (lookup) sil",
|
||||||
|
sysStoreLookupQuery: "Aramaları (lookup) sorgula",
|
||||||
|
sysSystemSettingsGet: "System settings settings getir",
|
||||||
|
sysSystemSettingsUpdate: "System settings settings güncelle",
|
||||||
|
taskIndexDocument: "Belgeyi dizine ekle",
|
||||||
|
taskUnindexDocument: "Belgeyi dizinden çıkar",
|
||||||
|
taskIndexTrace: "Telemetri izini (trace) dizine ekle",
|
||||||
|
taskCalendarAlarmEmail: "Takvim anımsatıcı e-postası",
|
||||||
|
taskCalendarAlarmNotification: "Takvim anımsatıcı bildirimi",
|
||||||
|
taskCalendarItipMessage: "Takvim iTIP iletisi",
|
||||||
|
taskMergeThreads: "E-posta yazışma dizilerini birleştir",
|
||||||
|
taskDmarcReport: "Uzak sunucuya DMARC raporu gönder",
|
||||||
|
taskTlsReport: "Uzak sunucuya TLS raporu gönder",
|
||||||
|
taskRestoreArchivedItem: "Arşivlenmiş ögeyi geri yükle",
|
||||||
|
taskDestroyAccount: "Account and all associated data sil",
|
||||||
|
taskAccountMaintenance: "account maintenance operations işlemini gerçekleştir",
|
||||||
|
taskTenantMaintenance: "tenant maintenance operations işlemini gerçekleştir",
|
||||||
|
taskStoreMaintenance: "store maintenance operations işlemini gerçekleştir",
|
||||||
|
taskSpamFilterMaintenance: "spam filter maintenance operations işlemini gerçekleştir",
|
||||||
|
taskAcmeRenewal: "ACME certificate renewal for a domain işlemini gerçekleştir",
|
||||||
|
taskDkimManagement: "DKIM key rotation for a domain işlemini gerçekleştir",
|
||||||
|
taskDnsManagement: "DNS management for a domain işlemini gerçekleştir",
|
||||||
|
sysTaskGet: "Görevleri getir",
|
||||||
|
sysTaskCreate: "Görevleri oluştur",
|
||||||
|
sysTaskUpdate: "Görevleri güncelle",
|
||||||
|
sysTaskDestroy: "Görevleri sil",
|
||||||
|
sysTaskQuery: "Görevleri sorgula",
|
||||||
|
sysTaskManagerGet: "Task manager settings getir",
|
||||||
|
sysTaskManagerUpdate: "Task manager settings güncelle",
|
||||||
|
sysTenantGet: "Kiracıları (tenant) getir",
|
||||||
|
sysTenantCreate: "Kiracıları (tenant) oluştur",
|
||||||
|
sysTenantUpdate: "Kiracıları (tenant) güncelle",
|
||||||
|
sysTenantDestroy: "Kiracıları (tenant) sil",
|
||||||
|
sysTenantQuery: "Kiracıları (tenant) sorgula",
|
||||||
|
sysTlsExternalReportGet: "Raporları getir",
|
||||||
|
sysTlsExternalReportCreate: "Raporları oluştur",
|
||||||
|
sysTlsExternalReportUpdate: "Raporları güncelle",
|
||||||
|
sysTlsExternalReportDestroy: "Raporları sil",
|
||||||
|
sysTlsExternalReportQuery: "Raporları sorgula",
|
||||||
|
sysTlsInternalReportGet: "Raporları getir",
|
||||||
|
sysTlsInternalReportCreate: "Raporları oluştur",
|
||||||
|
sysTlsInternalReportUpdate: "Raporları güncelle",
|
||||||
|
sysTlsInternalReportDestroy: "Raporları sil",
|
||||||
|
sysTlsInternalReportQuery: "Raporları sorgula",
|
||||||
|
sysTlsReportSettingsGet: "Tls report settings settings getir",
|
||||||
|
sysTlsReportSettingsUpdate: "Tls report settings settings güncelle",
|
||||||
|
sysTraceGet: "Izleme kayıtlarını (trace) getir",
|
||||||
|
sysTraceCreate: "Izleme kayıtlarını (trace) oluştur",
|
||||||
|
sysTraceUpdate: "Izleme kayıtlarını (trace) güncelle",
|
||||||
|
sysTraceDestroy: "Izleme kayıtlarını (trace) sil",
|
||||||
|
sysTraceQuery: "Izleme kayıtlarını (trace) sorgula",
|
||||||
|
sysTracerGet: "Izleyicileri (tracer) getir",
|
||||||
|
sysTracerCreate: "Izleyicileri (tracer) oluştur",
|
||||||
|
sysTracerUpdate: "Izleyicileri (tracer) güncelle",
|
||||||
|
sysTracerDestroy: "Izleyicileri (tracer) sil",
|
||||||
|
sysTracerQuery: "Izleyicileri (tracer) sorgula",
|
||||||
|
sysTracingStoreGet: "Tracing store settings getir",
|
||||||
|
sysTracingStoreUpdate: "Tracing store settings güncelle",
|
||||||
|
sysWebDavGet: "Web dav settings getir",
|
||||||
|
sysWebDavUpdate: "Web dav settings güncelle",
|
||||||
|
sysWebHookGet: "Webhook'ları getir",
|
||||||
|
sysWebHookCreate: "Webhook'ları oluştur",
|
||||||
|
sysWebHookUpdate: "Webhook'ları güncelle",
|
||||||
|
sysWebHookDestroy: "Webhook'ları sil",
|
||||||
|
sysWebHookQuery: "Webhook'ları sorgula",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -646,6 +646,12 @@ export const catalog: Catalog = {
|
|||||||
"+{n} more": "+{n} outros",
|
"+{n} more": "+{n} outros",
|
||||||
|
|
||||||
"Contacts": "Contatos",
|
"Contacts": "Contatos",
|
||||||
|
|
||||||
|
"Collected": "Coletados",
|
||||||
|
|
||||||
|
"Save people you write to as contacts": "Salvar como contatos as pessoas para quem você escreve",
|
||||||
|
|
||||||
|
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Todos para quem você envia e que ainda não são contatos são adicionados à agenda Coletados, para serem sugeridos em todos os dispositivos. Seus próprios endereços nunca são adicionados.",
|
||||||
"Contacts are not available": "Os contatos não estão disponíveis",
|
"Contacts are not available": "Os contatos não estão disponíveis",
|
||||||
"New contact": "Novo contato",
|
"New contact": "Novo contato",
|
||||||
"Edit contact": "Editar o contato",
|
"Edit contact": "Editar o contato",
|
||||||
@@ -730,6 +736,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────
|
||||||
"Settings": "Configurações",
|
"Settings": "Configurações",
|
||||||
|
"Show folder list": "Mostrar lista de pastas",
|
||||||
|
"Hide folder list": "Ocultar lista de pastas",
|
||||||
"All settings": "Todas as configurações",
|
"All settings": "Todas as configurações",
|
||||||
"Sections": "Seções",
|
"Sections": "Seções",
|
||||||
"General": "Geral",
|
"General": "Geral",
|
||||||
@@ -811,6 +819,9 @@ export const catalog: Catalog = {
|
|||||||
"Text size": "Tamanho do texto",
|
"Text size": "Tamanho do texto",
|
||||||
"Font size": "Tamanho da fonte",
|
"Font size": "Tamanho da fonte",
|
||||||
"Small": "Pequeno",
|
"Small": "Pequeno",
|
||||||
|
"Original size": "Tamanho original",
|
||||||
|
"Drag to resize": "Arraste para redimensionar",
|
||||||
|
"Image size": "Tamanho da imagem",
|
||||||
"Medium": "Médio",
|
"Medium": "Médio",
|
||||||
"Large": "Grande",
|
"Large": "Grande",
|
||||||
"Huge": "Muito grande",
|
"Huge": "Muito grande",
|
||||||
@@ -1328,6 +1339,9 @@ export const catalog: Catalog = {
|
|||||||
"Send anyway": "Enviar mesmo assim",
|
"Send anyway": "Enviar mesmo assim",
|
||||||
"Send canceled — the message is back in Drafts": "Envio cancelado — a mensagem voltou para Rascunhos",
|
"Send canceled — the message is back in Drafts": "Envio cancelado — a mensagem voltou para Rascunhos",
|
||||||
"Send failed: {error}": "Falha ao enviar: {error}",
|
"Send failed: {error}": "Falha ao enviar: {error}",
|
||||||
|
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Não foi possível verificar se esta mensagem já foi enviada. Confira Enviados antes de enviá-la novamente.",
|
||||||
|
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Não foi possível confirmar se esta mensagem foi enviada. Confira Enviados antes de enviá-la novamente.",
|
||||||
|
"This message had already been sent, so it wasn't sent again.": "Esta mensagem já tinha sido enviada, então não foi enviada de novo.",
|
||||||
"Send invites": "Enviar convites",
|
"Send invites": "Enviar convites",
|
||||||
"Send scheduled for {when}": "Envio agendado para {when}",
|
"Send scheduled for {when}": "Envio agendado para {when}",
|
||||||
"Send without a subject?": "Enviar sem assunto?",
|
"Send without a subject?": "Enviar sem assunto?",
|
||||||
|
|||||||
@@ -645,6 +645,12 @@ export const catalog: Catalog = {
|
|||||||
"+{n} more": "+{n}",
|
"+{n} more": "+{n}",
|
||||||
|
|
||||||
"Contacts": "Контакты",
|
"Contacts": "Контакты",
|
||||||
|
|
||||||
|
"Collected": "Собранные",
|
||||||
|
|
||||||
|
"Save people you write to as contacts": "Сохранять адресатов как контакты",
|
||||||
|
|
||||||
|
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Все, кому вы пишете и кого ещё нет в контактах, добавляются в адресную книгу «Собранные» и предлагаются на всех устройствах. Ваши собственные адреса не добавляются.",
|
||||||
"Contacts are not available": "Контакты недоступны",
|
"Contacts are not available": "Контакты недоступны",
|
||||||
"New contact": "Новый контакт",
|
"New contact": "Новый контакт",
|
||||||
"Edit contact": "Изменить контакт",
|
"Edit contact": "Изменить контакт",
|
||||||
@@ -730,6 +736,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────
|
||||||
"Settings": "Настройки",
|
"Settings": "Настройки",
|
||||||
|
"Show folder list": "Показать список папок",
|
||||||
|
"Hide folder list": "Скрыть список папок",
|
||||||
"All settings": "Все настройки",
|
"All settings": "Все настройки",
|
||||||
"Sections": "Разделы",
|
"Sections": "Разделы",
|
||||||
"General": "Общие",
|
"General": "Общие",
|
||||||
@@ -811,6 +819,9 @@ export const catalog: Catalog = {
|
|||||||
"Text size": "Размер текста",
|
"Text size": "Размер текста",
|
||||||
"Font size": "Размер шрифта",
|
"Font size": "Размер шрифта",
|
||||||
"Small": "Мелкий",
|
"Small": "Мелкий",
|
||||||
|
"Original size": "Исходный размер",
|
||||||
|
"Drag to resize": "Перетащите, чтобы изменить размер",
|
||||||
|
"Image size": "Размер изображения",
|
||||||
"Medium": "Средний",
|
"Medium": "Средний",
|
||||||
"Large": "Крупный",
|
"Large": "Крупный",
|
||||||
"Huge": "Очень крупный",
|
"Huge": "Очень крупный",
|
||||||
@@ -1327,6 +1338,9 @@ export const catalog: Catalog = {
|
|||||||
"Send anyway": "Всё равно отправить",
|
"Send anyway": "Всё равно отправить",
|
||||||
"Send canceled — the message is back in Drafts": "Отправка отменена — письмо вернулось в черновики",
|
"Send canceled — the message is back in Drafts": "Отправка отменена — письмо вернулось в черновики",
|
||||||
"Send failed: {error}": "Не удалось отправить: {error}",
|
"Send failed: {error}": "Не удалось отправить: {error}",
|
||||||
|
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Не удалось проверить, было ли это письмо уже отправлено. Проверьте «Отправленные», прежде чем отправлять его снова.",
|
||||||
|
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Не удалось подтвердить, было ли это письмо отправлено. Проверьте «Отправленные», прежде чем отправлять его снова.",
|
||||||
|
"This message had already been sent, so it wasn't sent again.": "Это письмо уже было отправлено, поэтому повторно оно не отправлялось.",
|
||||||
"Send invites": "Отправить приглашения",
|
"Send invites": "Отправить приглашения",
|
||||||
"Send scheduled for {when}": "Отправка запланирована на {when}",
|
"Send scheduled for {when}": "Отправка запланирована на {when}",
|
||||||
"Send without a subject?": "Отправить без темы?",
|
"Send without a subject?": "Отправить без темы?",
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -639,6 +639,12 @@ export const catalog: Catalog = {
|
|||||||
"+{n} more": "+{n}",
|
"+{n} more": "+{n}",
|
||||||
|
|
||||||
"Contacts": "Контакти",
|
"Contacts": "Контакти",
|
||||||
|
|
||||||
|
"Collected": "Зібрані",
|
||||||
|
|
||||||
|
"Save people you write to as contacts": "Зберігати адресатів як контакти",
|
||||||
|
|
||||||
|
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Усі, кому ви пишете і кого ще немає в контактах, додаються до адресної книги «Зібрані» й пропонуються на всіх пристроях. Ваші власні адреси не додаються.",
|
||||||
"Contacts are not available": "Контакти недоступні",
|
"Contacts are not available": "Контакти недоступні",
|
||||||
"New contact": "Новий контакт",
|
"New contact": "Новий контакт",
|
||||||
"Edit contact": "Змінити контакт",
|
"Edit contact": "Змінити контакт",
|
||||||
@@ -724,6 +730,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────
|
||||||
"Settings": "Налаштування",
|
"Settings": "Налаштування",
|
||||||
|
"Show folder list": "Показати список тек",
|
||||||
|
"Hide folder list": "Сховати список тек",
|
||||||
"All settings": "Усі налаштування",
|
"All settings": "Усі налаштування",
|
||||||
"Sections": "Розділи",
|
"Sections": "Розділи",
|
||||||
"General": "Загальні",
|
"General": "Загальні",
|
||||||
@@ -805,6 +813,9 @@ export const catalog: Catalog = {
|
|||||||
"Text size": "Розмір тексту",
|
"Text size": "Розмір тексту",
|
||||||
"Font size": "Розмір шрифту",
|
"Font size": "Розмір шрифту",
|
||||||
"Small": "Дрібний",
|
"Small": "Дрібний",
|
||||||
|
"Original size": "Початковий розмір",
|
||||||
|
"Drag to resize": "Перетягніть, щоб змінити розмір",
|
||||||
|
"Image size": "Розмір зображення",
|
||||||
"Medium": "Середній",
|
"Medium": "Середній",
|
||||||
"Large": "Великий",
|
"Large": "Великий",
|
||||||
"Huge": "Дуже великий",
|
"Huge": "Дуже великий",
|
||||||
@@ -1321,6 +1332,9 @@ export const catalog: Catalog = {
|
|||||||
"Send anyway": "Усе одно надіслати",
|
"Send anyway": "Усе одно надіслати",
|
||||||
"Send canceled — the message is back in Drafts": "Надсилання скасовано — лист повернувся до чернеток",
|
"Send canceled — the message is back in Drafts": "Надсилання скасовано — лист повернувся до чернеток",
|
||||||
"Send failed: {error}": "Не вдалося надіслати: {error}",
|
"Send failed: {error}": "Не вдалося надіслати: {error}",
|
||||||
|
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Не вдалося перевірити, чи цей лист уже надіслано. Перевірте «Надіслані», перш ніж надсилати його знову.",
|
||||||
|
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Не вдалося підтвердити, чи цей лист надіслано. Перевірте «Надіслані», перш ніж надсилати його знову.",
|
||||||
|
"This message had already been sent, so it wasn't sent again.": "Цей лист уже було надіслано, тому повторно його не надсилали.",
|
||||||
"Send invites": "Надіслати запрошення",
|
"Send invites": "Надіслати запрошення",
|
||||||
"Send scheduled for {when}": "Надсилання заплановано на {when}",
|
"Send scheduled for {when}": "Надсилання заплановано на {when}",
|
||||||
"Send without a subject?": "Надіслати без теми?",
|
"Send without a subject?": "Надіслати без теми?",
|
||||||
|
|||||||
@@ -641,6 +641,12 @@ export const catalog: Catalog = {
|
|||||||
"+{n} more": "还有 {n} 项",
|
"+{n} more": "还有 {n} 项",
|
||||||
|
|
||||||
"Contacts": "联系人",
|
"Contacts": "联系人",
|
||||||
|
|
||||||
|
"Collected": "已收集",
|
||||||
|
|
||||||
|
"Save people you write to as contacts": "将您发信的联系人保存为联系人",
|
||||||
|
|
||||||
|
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "您发信的对象如果还不是联系人,会被添加到“已收集”通讯录,在所有设备上都会作为建议显示。您自己的地址不会被添加。",
|
||||||
"Contacts are not available": "联系人不可用",
|
"Contacts are not available": "联系人不可用",
|
||||||
"New contact": "新建联系人",
|
"New contact": "新建联系人",
|
||||||
"Edit contact": "编辑联系人",
|
"Edit contact": "编辑联系人",
|
||||||
@@ -726,6 +732,8 @@ export const catalog: Catalog = {
|
|||||||
|
|
||||||
// ── Settings ───────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────
|
||||||
"Settings": "设置",
|
"Settings": "设置",
|
||||||
|
"Show folder list": "显示文件夹列表",
|
||||||
|
"Hide folder list": "隐藏文件夹列表",
|
||||||
"All settings": "全部设置",
|
"All settings": "全部设置",
|
||||||
"Sections": "分区",
|
"Sections": "分区",
|
||||||
"General": "常规",
|
"General": "常规",
|
||||||
@@ -807,6 +815,9 @@ export const catalog: Catalog = {
|
|||||||
"Text size": "文字大小",
|
"Text size": "文字大小",
|
||||||
"Font size": "字号",
|
"Font size": "字号",
|
||||||
"Small": "小",
|
"Small": "小",
|
||||||
|
"Original size": "原始大小",
|
||||||
|
"Drag to resize": "拖动以调整大小",
|
||||||
|
"Image size": "图片大小",
|
||||||
"Medium": "中",
|
"Medium": "中",
|
||||||
"Large": "大",
|
"Large": "大",
|
||||||
"Huge": "特大",
|
"Huge": "特大",
|
||||||
@@ -1332,6 +1343,9 @@ export const catalog: Catalog = {
|
|||||||
"Send anyway": "仍然发送",
|
"Send anyway": "仍然发送",
|
||||||
"Send canceled — the message is back in Drafts": "已取消发送——邮件已回到草稿箱",
|
"Send canceled — the message is back in Drafts": "已取消发送——邮件已回到草稿箱",
|
||||||
"Send failed: {error}": "发送失败:{error}",
|
"Send failed: {error}": "发送失败:{error}",
|
||||||
|
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "无法检查这封邮件是否已发送。请先查看“已发送”,再决定是否重新发送。",
|
||||||
|
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "无法确认这封邮件是否已发送。请先查看“已发送”,再决定是否重新发送。",
|
||||||
|
"This message had already been sent, so it wasn't sent again.": "这封邮件已经发送过,因此没有再次发送。",
|
||||||
"Send invites": "发送邀请",
|
"Send invites": "发送邀请",
|
||||||
"Send scheduled for {when}": "已定时于 {when} 发送",
|
"Send scheduled for {when}": "已定时于 {when} 发送",
|
||||||
"Send without a subject?": "不填主题就发送吗?",
|
"Send without a subject?": "不填主题就发送吗?",
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { CAP, client } from "@/jmap/client";
|
||||||
|
import { useCompose } from "@/store/compose";
|
||||||
|
import { useContacts } from "@/store/contacts";
|
||||||
|
import { useMail } from "@/store/mail";
|
||||||
|
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
|
||||||
|
import type { JmapSession } from "@/jmap/types";
|
||||||
|
|
||||||
|
/* A confirmed send hands its recipients to the contacts store, unless the reader turned that off. */
|
||||||
|
|
||||||
|
function okServer() {
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||||
|
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||||
|
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||||
|
if (name === "Email/set" && args.create) return [name, { accountId: "a1", oldState: "1", newState: "2", created: { m: { id: "e1" } } }, id];
|
||||||
|
if (name === "EmailSubmission/set") return [name, { accountId: "a1", oldState: "1", newState: "2", created: { s: { id: "s1" } } }, id];
|
||||||
|
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
|
||||||
|
});
|
||||||
|
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let collect: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client.session = {
|
||||||
|
capabilities: { [CAP.core]: {}, [CAP.mail]: {}, [CAP.submission]: {} },
|
||||||
|
accounts: { a1: { accountCapabilities: { [CAP.mail]: {}, [CAP.submission]: {} } } },
|
||||||
|
primaryAccounts: {},
|
||||||
|
state: "s1",
|
||||||
|
} as unknown as JmapSession;
|
||||||
|
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, undoSendSeconds: 0 } });
|
||||||
|
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
|
||||||
|
useMail.setState({
|
||||||
|
accountId: "a1",
|
||||||
|
identities: [{ id: "i1", name: "Me", email: "[email protected]", replyTo: null }] as never,
|
||||||
|
mailboxes: { mbSent: { id: "mbSent", role: "sent", parentId: null, name: "Sent" } } as never,
|
||||||
|
});
|
||||||
|
collect = vi.fn(async () => 1);
|
||||||
|
useContacts.setState({ collectRecipients: collect as never });
|
||||||
|
okServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
const key = useCompose.getState().open({ to: [{ name: "Ann", email: "[email protected]" }], cc: [{ name: null, email: "[email protected]" }], subject: "Hi" });
|
||||||
|
await useCompose.getState().send(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("collecting recipients when a message is sent", () => {
|
||||||
|
it("passes every recipient, and the sender's own addresses to leave out", async () => {
|
||||||
|
await send();
|
||||||
|
expect(collect).toHaveBeenCalledTimes(1);
|
||||||
|
const [addrs, own] = collect.mock.calls[0]!;
|
||||||
|
expect((addrs as { email: string }[]).map((a) => a.email)).toEqual(["[email protected]", "[email protected]"]);
|
||||||
|
expect(own).toEqual(["[email protected]"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not when the setting is off", async () => {
|
||||||
|
useSettings.setState((s) => ({ settings: { ...s.settings, collectRecipients: false } }));
|
||||||
|
await send();
|
||||||
|
expect(collect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { CAP, client } from "@/jmap/client";
|
||||||
|
import { useContacts } from "@/store/contacts";
|
||||||
|
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
|
||||||
|
import type { ContactCard, JmapSession } from "@/jmap/types";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* People written to used to be remembered only in this browser, as a list of
|
||||||
|
* recent addresses, so a new device suggested nobody. Each confirmed send now
|
||||||
|
* saves the recipients who are not contacts yet, in an address book of their
|
||||||
|
* own, on the server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Calls {
|
||||||
|
books: Record<string, unknown>[];
|
||||||
|
cards: Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function server(): Calls {
|
||||||
|
const calls: Calls = { books: [], cards: [] };
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||||
|
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||||
|
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||||
|
if (name === "AddressBook/set" && args.create) {
|
||||||
|
calls.books.push(args.create as Record<string, unknown>);
|
||||||
|
useContacts.setState((s) => ({ books: { ...s.books, bNew: { id: "bNew", name: "Collected" } as never } }));
|
||||||
|
return [name, { accountId: "a1", oldState: "1", newState: "2", created: { b: { id: "bNew" } } }, id];
|
||||||
|
}
|
||||||
|
if (name === "ContactCard/set" && args.create) {
|
||||||
|
const create = args.create as Record<string, unknown>;
|
||||||
|
calls.cards.push(...Object.values(create) as Record<string, unknown>[]);
|
||||||
|
return [name, { accountId: "a1", oldState: "1", newState: "2", created: Object.fromEntries(Object.keys(create).map((k, i) => [k, { id: `n${i}` }])) }, id];
|
||||||
|
}
|
||||||
|
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false, changed: [], created: [], updated: [], destroyed: [], hasMoreChanges: false, oldState: "1", newState: "1" }, id];
|
||||||
|
});
|
||||||
|
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
||||||
|
}));
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = (id: string, email: string): ContactCard =>
|
||||||
|
({ id, uid: id, addressBookIds: { b1: true }, name: { full: id }, emails: { e: { address: email } } }) as unknown as ContactCard;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client.session = {
|
||||||
|
capabilities: { [CAP.core]: { maxObjectsInSet: 500 }, [CAP.contacts]: {} },
|
||||||
|
accounts: { a1: { accountCapabilities: { [CAP.contacts]: {} } } },
|
||||||
|
primaryAccounts: { [CAP.contacts]: "a1" },
|
||||||
|
state: "s1",
|
||||||
|
} as unknown as JmapSession;
|
||||||
|
useSettings.setState({ settings: { ...DEFAULT_SETTINGS } });
|
||||||
|
useContacts.setState({
|
||||||
|
accountId: "a1",
|
||||||
|
available: true,
|
||||||
|
loaded: true,
|
||||||
|
books: { b1: { id: "b1", name: "Personal" } as never },
|
||||||
|
cards: { c1: card("c1", "[email protected]") },
|
||||||
|
sharedCards: {},
|
||||||
|
syncCards: (async () => undefined) as never,
|
||||||
|
loadBooks: (async () => undefined) as never,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saving the people written to", () => {
|
||||||
|
it("adds only addresses that are not contacts yet, and never your own", async () => {
|
||||||
|
const calls = server();
|
||||||
|
const n = await useContacts.getState().collectRecipients(
|
||||||
|
[
|
||||||
|
{ name: "Known Person", email: "[email protected]" },
|
||||||
|
{ name: "Smith, Jane", email: "[email protected]" },
|
||||||
|
{ name: null, email: "[email protected]" },
|
||||||
|
{ name: "Jane again", email: "[email protected]" },
|
||||||
|
],
|
||||||
|
["[email protected]"],
|
||||||
|
);
|
||||||
|
expect(n).toBe(1);
|
||||||
|
expect(calls.cards).toHaveLength(1);
|
||||||
|
const added = calls.cards[0]!;
|
||||||
|
expect(Object.values(added.emails as Record<string, { address: string }>)[0]!.address).toBe("[email protected]");
|
||||||
|
// Filed under the Collected book, with the name split the way the editor does.
|
||||||
|
expect(added.addressBookIds).toEqual({ bNew: true });
|
||||||
|
expect((added.name as { components: { kind: string; value: string }[] }).components.map((c) => c.value)).toEqual(expect.arrayContaining(["Jane", "Smith"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates the Collected book once and remembers it", async () => {
|
||||||
|
const calls = server();
|
||||||
|
await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||||
|
expect(calls.books).toHaveLength(1);
|
||||||
|
expect(useSettings.getState().settings.collectedBookId).toBe("bNew");
|
||||||
|
await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||||
|
expect(calls.books).toHaveLength(1);
|
||||||
|
expect(calls.cards.at(-1)!.addressBookIds).toEqual({ bNew: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes a new book when the remembered one has been deleted", async () => {
|
||||||
|
const calls = server();
|
||||||
|
useSettings.setState((s) => ({ settings: { ...s.settings, collectedBookId: "gone" } }));
|
||||||
|
await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||||
|
expect(calls.books).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing, and makes no book, when everyone is already a contact", async () => {
|
||||||
|
const calls = server();
|
||||||
|
const n = await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||||
|
expect(n).toBe(0);
|
||||||
|
expect(calls.books).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { useContacts } from "@/store/contacts";
|
||||||
|
import type { ContactCard } from "@/jmap/types";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* What the recipient field offers as you type. Matching used to take the
|
||||||
|
* typed text as one piece, so "jane smi" found nobody filed as "Smith, Jane",
|
||||||
|
* and a nickname or a company name found nothing at all.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const card = (id: string, full: string, email: string, extra: Partial<ContactCard> = {}): ContactCard =>
|
||||||
|
({ id, uid: id, addressBookIds: { b1: true }, name: { full }, emails: { e: { address: email } }, ...extra }) as unknown as ContactCard;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useContacts.setState({
|
||||||
|
accountId: "a1",
|
||||||
|
available: false,
|
||||||
|
loaded: true,
|
||||||
|
loading: false,
|
||||||
|
principalsLoaded: true,
|
||||||
|
principals: [],
|
||||||
|
sharedCards: {},
|
||||||
|
recent: [],
|
||||||
|
cards: {
|
||||||
|
c1: card("c1", "Smith, Jane", "[email protected]"),
|
||||||
|
c2: card("c2", "Robert Jones", "[email protected]", { nicknames: { n: { name: "Bobby" } } } as Partial<ContactCard>),
|
||||||
|
c3: card("c3", "Ann Lee", "[email protected]", { organizations: { o: { name: "Globex Industries" } } } as Partial<ContactCard>),
|
||||||
|
c4: card("c4", "Ann Taylor", "[email protected]"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const emails = async (q: string) => (await useContacts.getState().suggest(q)).map((s) => s.email);
|
||||||
|
|
||||||
|
describe("recipient suggestions", () => {
|
||||||
|
it("matches the words typed in any order", async () => {
|
||||||
|
expect(await emails("jane smi")).toEqual(["[email protected]"]);
|
||||||
|
expect(await emails("smi jan")).toEqual(["[email protected]"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not match when one of the words fits nobody", async () => {
|
||||||
|
expect(await emails("jane xyz")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches a nickname and an organization", async () => {
|
||||||
|
expect(await emails("bobby")).toEqual(["[email protected]"]);
|
||||||
|
expect(await emails("globex")).toEqual(["[email protected]"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts someone written to lately ahead of an equal match", async () => {
|
||||||
|
expect(await emails("ann")).toEqual(["[email protected]", "[email protected]"]);
|
||||||
|
useContacts.setState({ recent: [{ name: "Ann Taylor", email: "[email protected]" }] });
|
||||||
|
expect((await emails("ann"))[0]).toBe("[email protected]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still ranks a match at the start above a word inside the name", async () => {
|
||||||
|
// "jo" starts Jones's surname word; it starts no other card's name or address.
|
||||||
|
expect(await emails("jo")).toEqual(["[email protected]"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ApiError, CAP, client } from "@/jmap/client";
|
||||||
|
import { sendOutcomeUnknown, useCompose } from "@/store/compose";
|
||||||
|
import { useMail } from "@/store/mail";
|
||||||
|
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
|
||||||
|
import { useToasts } from "@/ui/toast";
|
||||||
|
import type { JmapSession } from "@/jmap/types";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A send whose reply goes missing may still have gone out. "Send failed ->
|
||||||
|
* Open draft -> Send" then delivered it twice: nothing said which message
|
||||||
|
* the first attempt was, so nothing could check. Each send now carries its
|
||||||
|
* own Message-ID, and an unanswered send asks the server what happened to it
|
||||||
|
* before calling it failed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Mode = "ok" | "lost-reply" | "never-arrived" | "orphan" | "gateway" | "refused" | "offline-after";
|
||||||
|
|
||||||
|
interface Server {
|
||||||
|
emails: { id: string; messageId: string }[];
|
||||||
|
submissions: { id: string; emailId: string }[];
|
||||||
|
destroyed: string[];
|
||||||
|
creates: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function server(mode: Mode): Server {
|
||||||
|
const st: Server = { emails: [], submissions: [], destroyed: [], creates: 0 };
|
||||||
|
let first = true;
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||||
|
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||||
|
const sending = body.methodCalls.some(([n]) => n === "EmailSubmission/set");
|
||||||
|
if (sending && first && mode !== "ok") {
|
||||||
|
first = false;
|
||||||
|
if (mode === "never-arrived") throw new TypeError("Failed to fetch");
|
||||||
|
if (mode === "gateway") return { ok: false, status: 502, statusText: "Bad Gateway", json: async () => ({ error: "bad_gateway" }) } as Response;
|
||||||
|
if (mode === "refused") return { ok: false, status: 400, statusText: "Bad Request", json: async () => ({ type: "urn:ietf:params:jmap:error:notRequest" }) } as Response;
|
||||||
|
// The server does the work; only the answer is lost.
|
||||||
|
const [, args] = body.methodCalls.find(([n, a]) => n === "Email/set" && (a as { create?: unknown }).create)!;
|
||||||
|
const m = (args.create as { m: { messageId: string[] } }).m;
|
||||||
|
const id = `e${st.emails.length + 1}`;
|
||||||
|
st.creates++;
|
||||||
|
st.emails.push({ id, messageId: m.messageId[0]! });
|
||||||
|
if (mode !== "orphan") st.submissions.push({ id: `s${id}`, emailId: id });
|
||||||
|
throw new TypeError("Failed to fetch");
|
||||||
|
}
|
||||||
|
if (mode === "offline-after" && !first) throw new TypeError("Failed to fetch");
|
||||||
|
if (mode === "offline-after" && sending) {
|
||||||
|
first = false;
|
||||||
|
throw new TypeError("Failed to fetch");
|
||||||
|
}
|
||||||
|
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||||
|
if (name === "Email/set" && (args as { create?: unknown }).create) {
|
||||||
|
const m = (args.create as { m: { messageId: string[] } }).m;
|
||||||
|
const eid = `e${st.emails.length + 1}`;
|
||||||
|
st.creates++;
|
||||||
|
st.emails.push({ id: eid, messageId: m.messageId[0]! });
|
||||||
|
return [name, { accountId: "a1", oldState: "1", newState: "2", created: { m: { id: eid } } }, id];
|
||||||
|
}
|
||||||
|
if (name === "EmailSubmission/set") {
|
||||||
|
const eid = st.emails[st.emails.length - 1]!.id;
|
||||||
|
st.submissions.push({ id: `s${eid}`, emailId: eid });
|
||||||
|
return [name, { accountId: "a1", oldState: "1", newState: "2", created: { s: { id: `s${eid}` } } }, id];
|
||||||
|
}
|
||||||
|
if (name === "Email/set" && (args as { destroy?: string[] }).destroy) {
|
||||||
|
st.destroyed.push(...((args as { destroy: string[] }).destroy));
|
||||||
|
return [name, { accountId: "a1", oldState: "1", newState: "2", destroyed: (args as { destroy: string[] }).destroy }, id];
|
||||||
|
}
|
||||||
|
if (name === "Email/query") {
|
||||||
|
const [, value] = ((args.filter ?? {}) as { header?: [string, string] }).header ?? [];
|
||||||
|
const ids = value ? st.emails.filter((e) => e.messageId === value && !st.destroyed.includes(e.id)).map((e) => e.id) : [];
|
||||||
|
return [name, { accountId: "a1", queryState: "q", canCalculateChanges: false, position: 0, ids, total: ids.length }, id];
|
||||||
|
}
|
||||||
|
if (name === "EmailSubmission/query") {
|
||||||
|
const want = ((args.filter ?? {}) as { emailIds?: string[] }).emailIds ?? [];
|
||||||
|
const ids = st.submissions.filter((s) => want.includes(s.emailId)).map((s) => s.id);
|
||||||
|
return [name, { accountId: "a1", queryState: "q", canCalculateChanges: false, position: 0, ids, total: ids.length }, id];
|
||||||
|
}
|
||||||
|
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
|
||||||
|
});
|
||||||
|
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
||||||
|
}));
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTexts = () => useToasts.getState().toasts.map((t) => t.message);
|
||||||
|
|
||||||
|
async function sendOne(init: Record<string, unknown> = {}) {
|
||||||
|
const key = useCompose.getState().open({ to: [{ name: null, email: "[email protected]" }], subject: "Hi", ...init });
|
||||||
|
await useCompose.getState().send(key);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client.session = {
|
||||||
|
capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.mail]: {}, [CAP.submission]: {} },
|
||||||
|
accounts: { a1: { accountCapabilities: { [CAP.mail]: {}, [CAP.submission]: {} } } },
|
||||||
|
primaryAccounts: {},
|
||||||
|
state: "s1",
|
||||||
|
} as unknown as JmapSession;
|
||||||
|
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, undoSendSeconds: 0 } });
|
||||||
|
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
|
||||||
|
useMail.setState({
|
||||||
|
accountId: "a1",
|
||||||
|
identities: [{ id: "i1", name: "John", email: "[email protected]", replyTo: null }] as never,
|
||||||
|
mailboxes: {
|
||||||
|
mbSent: { id: "mbSent", role: "sent", parentId: null, name: "Sent" },
|
||||||
|
mbDrafts: { id: "mbDrafts", role: "drafts", parentId: null, name: "Drafts" },
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
useToasts.setState({ toasts: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("which failures leave the outcome open", () => {
|
||||||
|
it("treats a refusal as not sent, and no answer or a gateway error as unknown", () => {
|
||||||
|
expect(sendOutcomeUnknown(new ApiError(400, "bad"))).toBe(false);
|
||||||
|
expect(sendOutcomeUnknown(new ApiError(401, "unauthenticated"))).toBe(false);
|
||||||
|
expect(sendOutcomeUnknown(new ApiError(502, "bad_gateway"))).toBe(true);
|
||||||
|
expect(sendOutcomeUnknown(new ApiError(408, "timeout"))).toBe(true);
|
||||||
|
expect(sendOutcomeUnknown(new TypeError("Failed to fetch"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sending once, whatever goes wrong on the way back", () => {
|
||||||
|
it("gives every send a Message-ID on the sender's domain", async () => {
|
||||||
|
const st = server("ok");
|
||||||
|
await sendOne();
|
||||||
|
expect(st.emails).toHaveLength(1);
|
||||||
|
expect(st.emails[0]!.messageId).toMatch(/^[0-9a-f-]{36}@example\.org$/);
|
||||||
|
expect(toastTexts()).toContain("Message sent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls it sent when the reply was lost but the server sent it", async () => {
|
||||||
|
const st = server("lost-reply");
|
||||||
|
await sendOne();
|
||||||
|
expect(st.submissions).toHaveLength(1);
|
||||||
|
expect(st.destroyed).toEqual([]);
|
||||||
|
expect(toastTexts()).toContain("Message sent");
|
||||||
|
expect(useCompose.getState().drafts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a message that was created but never submitted, and says it failed", async () => {
|
||||||
|
const st = server("orphan");
|
||||||
|
await sendOne();
|
||||||
|
expect(st.destroyed).toEqual(["e1"]);
|
||||||
|
expect(toastTexts().some((t) => t.startsWith("Send failed"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says it failed when the request never reached the server", async () => {
|
||||||
|
const st = server("never-arrived");
|
||||||
|
await sendOne();
|
||||||
|
expect(st.emails).toHaveLength(0);
|
||||||
|
expect(toastTexts().some((t) => t.startsWith("Send failed"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks the server after a gateway error too, rather than assuming", async () => {
|
||||||
|
const st = server("gateway");
|
||||||
|
await sendOne();
|
||||||
|
expect(st.creates).toBe(0);
|
||||||
|
expect(toastTexts().some((t) => t.startsWith("Send failed"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not ask after a refusal: the server did not run it", async () => {
|
||||||
|
server("refused");
|
||||||
|
const fetchMock = vi.mocked(fetch);
|
||||||
|
await sendOne();
|
||||||
|
const asked = fetchMock.mock.calls.some(([, init]) => (init as RequestInit).body?.toString().includes("Email/query"));
|
||||||
|
expect(asked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says plainly when it cannot tell, instead of offering a resend that could duplicate", async () => {
|
||||||
|
server("offline-after");
|
||||||
|
await sendOne();
|
||||||
|
const msgs = toastTexts();
|
||||||
|
expect(msgs.some((t) => t.includes("Couldn't confirm whether this message was sent"))).toBe(true);
|
||||||
|
expect(msgs.some((t) => t.startsWith("Send failed"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sending a draft again after a failure", () => {
|
||||||
|
it("keeps the Message-ID, and sends nothing when the first attempt went out", async () => {
|
||||||
|
const st = server("ok");
|
||||||
|
// The first attempt went out; the client never heard.
|
||||||
|
st.emails.push({ id: "e1", messageId: "[email protected]" });
|
||||||
|
st.submissions.push({ id: "se1", emailId: "e1" });
|
||||||
|
await sendOne({ sendMessageId: "[email protected]" });
|
||||||
|
expect(st.creates).toBe(0);
|
||||||
|
expect(toastTexts().some((t) => t.includes("had already been sent"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends it, under the same Message-ID, when the first attempt did not go out", async () => {
|
||||||
|
const st = server("ok");
|
||||||
|
await sendOne({ sendMessageId: "[email protected]" });
|
||||||
|
expect(st.creates).toBe(1);
|
||||||
|
expect(st.emails[0]!.messageId).toBe("[email protected]");
|
||||||
|
expect(toastTexts()).toContain("Message sent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reopens a failed draft with its Message-ID, so the next send can check", async () => {
|
||||||
|
server("orphan");
|
||||||
|
await sendOne();
|
||||||
|
const toast = useToasts.getState().toasts.find((t) => t.message.startsWith("Send failed"))!;
|
||||||
|
toast.action!.onClick();
|
||||||
|
const reopened = useCompose.getState().drafts[0]!;
|
||||||
|
expect(reopened.sendMessageId).toMatch(/@example\.org$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
+120
-6
@@ -1,5 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { client, setErrorMessage } from "@/jmap/client";
|
import { ApiError, client, setErrorMessage } from "@/jmap/client";
|
||||||
import type { Email, EmailAddress, EmailBodyPart, Id, Identity, SetResponse } from "@/jmap/types";
|
import type { Email, EmailAddress, EmailBodyPart, Id, Identity, SetResponse } from "@/jmap/types";
|
||||||
import { formatFullDate, uid } from "@/lib/format";
|
import { formatFullDate, uid } from "@/lib/format";
|
||||||
import { formatAddress, parseMailto, sameAddress, uniqueAddresses } from "@/lib/address";
|
import { formatAddress, parseMailto, sameAddress, uniqueAddresses } from "@/lib/address";
|
||||||
@@ -102,6 +102,12 @@ export interface Draft {
|
|||||||
dlp?: DlpRefusalInfo | null;
|
dlp?: DlpRefusalInfo | null;
|
||||||
/** The reason to send despite a DLP warning, for the next send only. */
|
/** The reason to send despite a DLP warning, for the next send only. */
|
||||||
dlpOverride?: string | null;
|
dlpOverride?: string | null;
|
||||||
|
/**
|
||||||
|
* The Message-ID this draft goes out under, fixed at its first send and
|
||||||
|
* kept if the draft comes back after a failure. Sending it again asks the
|
||||||
|
* server first whether a message with this id already went out.
|
||||||
|
*/
|
||||||
|
sendMessageId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DlpRefusalInfo {
|
export interface DlpRefusalInfo {
|
||||||
@@ -738,8 +744,12 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
async send(key) {
|
async send(key) {
|
||||||
const d = get().drafts.find((x) => x.key === key);
|
const found = get().drafts.find((x) => x.key === key);
|
||||||
if (!d) return;
|
if (!found) return;
|
||||||
|
// A draft that already has a Message-ID has been sent before, and failed.
|
||||||
|
const retry = Boolean(found.sendMessageId);
|
||||||
|
const fromEmail = (useMail.getState().identities.find((i) => i.id === found.identityId) ?? useMail.getState().identities[0])?.email ?? "";
|
||||||
|
const d: Draft = { ...found, sendMessageId: found.sendMessageId ?? newMessageId(fromEmail) };
|
||||||
const delay = settings().undoSendSeconds;
|
const delay = settings().undoSendSeconds;
|
||||||
// A schedule the user left sitting until it passed is just a send now.
|
// A schedule the user left sitting until it passed is just a send now.
|
||||||
const scheduling = d.sendAt !== null && d.sendAt > Date.now();
|
const scheduling = d.sendAt !== null && d.sendAt > Date.now();
|
||||||
@@ -754,7 +764,18 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
|||||||
return { pendingSends: rest };
|
return { pendingSends: rest };
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const sent = await sendInternal(d, get);
|
const sent = await sendInternal(d, get, { retry });
|
||||||
|
// Everyone written to who is not a contact yet, so they are suggested on every
|
||||||
|
// device. Also when a resend found the first attempt had gone out: its reply
|
||||||
|
// was what got lost, so nothing was collected then.
|
||||||
|
if (settings().collectRecipients) {
|
||||||
|
const own = useMail.getState().identities.map((i) => i.email);
|
||||||
|
void useContacts.getState().collectRecipients([...d.to, ...d.cc, ...d.bcc], own).catch(() => undefined);
|
||||||
|
}
|
||||||
|
if (sent.alreadySent) {
|
||||||
|
toast.success(translate("This message had already been sent, so it wasn't sent again."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast.success(
|
toast.success(
|
||||||
sent.held
|
sent.held
|
||||||
? translate("Held for review: it's sent once a reviewer releases it")
|
? translate("Held for review: it's sent once a reviewer releases it")
|
||||||
@@ -769,6 +790,13 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
|||||||
toast.error(err.info.kind === "warning" ? translate("Not sent yet: check the warning") : translate("Not sent: blocked by the server's rules"));
|
toast.error(err.info.kind === "warning" ? translate("Not sent yet: check the warning") : translate("Not sent: blocked by the server's rules"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (err instanceof SendOutcomeUnknown) {
|
||||||
|
toast.error(err.message, {
|
||||||
|
action: { label: translate("Open draft"), onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: err.message }], activeKey: d.key })) },
|
||||||
|
duration: 30000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast.error(translate("Send failed: {error}", { error: (err as Error).message }), {
|
toast.error(translate("Send failed: {error}", { error: (err as Error).message }), {
|
||||||
action: { label: translate("Open draft"), onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: (err as Error).message }], activeKey: d.key })) },
|
action: { label: translate("Open draft"), onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: (err as Error).message }], activeKey: d.key })) },
|
||||||
duration: 15000,
|
duration: 15000,
|
||||||
@@ -1082,9 +1110,54 @@ export function buildSubmission(opts: {
|
|||||||
/** What the server said of a send: whether DLP held it for review (inbuxa). */
|
/** What the server said of a send: whether DLP held it for review (inbuxa). */
|
||||||
interface Sent {
|
interface Sent {
|
||||||
held: boolean;
|
held: boolean;
|
||||||
|
/** A resend found the first attempt had already gone out; nothing was sent again. */
|
||||||
|
alreadySent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendInternal(d: Draft, _get: () => ComposeState): Promise<Sent> {
|
/**
|
||||||
|
* A send whose outcome nobody can state: the request went out, no answer came
|
||||||
|
* back, and the server could not be asked afterwards either. Offering a plain
|
||||||
|
* "send again" here is how one message is delivered twice.
|
||||||
|
*/
|
||||||
|
export class SendOutcomeUnknown extends Error {}
|
||||||
|
|
||||||
|
/** A Message-ID for one send, on the sending identity's domain (RFC 5322 §3.6.4). */
|
||||||
|
export function newMessageId(fromEmail: string): string {
|
||||||
|
const domain = fromEmail.split("@")[1] || "localhost";
|
||||||
|
return `${crypto.randomUUID()}@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a failed request may still have been carried out. A refusal (4xx)
|
||||||
|
* means the server did not run it; no answer, or a 5xx from the proxy
|
||||||
|
* that lost the upstream reply, means it may have.
|
||||||
|
*/
|
||||||
|
export function sendOutcomeUnknown(err: unknown): boolean {
|
||||||
|
if (err instanceof ApiError) return err.status >= 500 || err.status === 408;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What became of a send with this Message-ID.
|
||||||
|
*
|
||||||
|
* `sent`: a message carrying it exists and was submitted. `orphan`: it exists
|
||||||
|
* but no submission does, so it was created and never sent. `none`: nothing.
|
||||||
|
*
|
||||||
|
* Submissions are expunged after the server's hold period, so "no submission"
|
||||||
|
* means unsent only right after the attempt (`justNow`). Later, a message
|
||||||
|
* still carrying the id is taken as sent: every path that fails a send
|
||||||
|
* destroys the copy it created.
|
||||||
|
*/
|
||||||
|
export async function traceSend(accountId: Id, messageId: string, justNow: boolean): Promise<{ trace: "sent" | "orphan" | "none"; emailIds: Id[] }> {
|
||||||
|
const q = await client.call<{ ids: Id[] }>("Email/query", { accountId, filter: { header: ["Message-ID", messageId] } });
|
||||||
|
const emailIds = q.ids ?? [];
|
||||||
|
if (!emailIds.length) return { trace: "none", emailIds };
|
||||||
|
if (!justNow) return { trace: "sent", emailIds };
|
||||||
|
const subs = await client.call<{ ids: Id[] }>("EmailSubmission/query", { accountId, filter: { emailIds } });
|
||||||
|
return { trace: subs.ids?.length ? "sent" : "orphan", emailIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendInternal(d: Draft, _get: () => ComposeState, opts: { retry?: boolean } = {}): Promise<Sent> {
|
||||||
const mail = useMail.getState();
|
const mail = useMail.getState();
|
||||||
const accountId = mail.accountId!;
|
const accountId = mail.accountId!;
|
||||||
const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0];
|
const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0];
|
||||||
@@ -1093,6 +1166,27 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<Sent> {
|
|||||||
const scheduled = d.sendAt !== null && d.sendAt > Date.now();
|
const scheduled = d.sendAt !== null && d.sendAt > Date.now();
|
||||||
const scheduledId = scheduled ? await ensureScheduledMailbox() : null;
|
const scheduledId = scheduled ? await ensureScheduledMailbox() : null;
|
||||||
const email = await buildEmailObject(d, { forSend: true, mailboxId: scheduledId });
|
const email = await buildEmailObject(d, { forSend: true, mailboxId: scheduledId });
|
||||||
|
const messageId = d.sendMessageId ?? newMessageId(ident.email);
|
||||||
|
email.messageId = [messageId];
|
||||||
|
/*
|
||||||
|
* Sending again a draft that came back from a failed send: ask first. The
|
||||||
|
* failure may have been only the reply going missing, and a second
|
||||||
|
* submission would deliver the message twice.
|
||||||
|
*/
|
||||||
|
if (opts.retry) {
|
||||||
|
let found: Awaited<ReturnType<typeof traceSend>>;
|
||||||
|
try {
|
||||||
|
found = await traceSend(accountId, messageId, false);
|
||||||
|
} catch {
|
||||||
|
throw new SendOutcomeUnknown(translate("Couldn't check whether this message was already sent. Check Sent before sending it again."));
|
||||||
|
}
|
||||||
|
if (found.trace === "sent") {
|
||||||
|
if (d.draftId) void client.call("Email/set", { accountId, destroy: [d.draftId] });
|
||||||
|
void mail.loadMailboxes();
|
||||||
|
void mail.refreshList();
|
||||||
|
return { held: false, alreadySent: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
const sentId = mail.roleId("sent");
|
const sentId = mail.roleId("sent");
|
||||||
const draftsId = mail.roleId("drafts");
|
const draftsId = mail.roleId("drafts");
|
||||||
const rcpts = uniqueAddresses([...d.to, ...d.cc, ...d.bcc]).map((a) => ({ email: a.email }));
|
const rcpts = uniqueAddresses([...d.to, ...d.cc, ...d.bcc]).map((a) => ({ email: a.email }));
|
||||||
@@ -1119,7 +1213,27 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<Sent> {
|
|||||||
if (d.relatedEmailId && d.relatedKeyword) {
|
if (d.relatedEmailId && d.relatedKeyword) {
|
||||||
calls.push(["Email/set", { accountId, update: { [d.relatedEmailId]: { [`keywords/${d.relatedKeyword}`]: true } } }, "k"]);
|
calls.push(["Email/set", { accountId, update: { [d.relatedEmailId]: { [`keywords/${d.relatedKeyword}`]: true } } }, "k"]);
|
||||||
}
|
}
|
||||||
const res = await client.chain(calls, { allowErrors: true });
|
let res: Awaited<ReturnType<typeof client.chain>>;
|
||||||
|
try {
|
||||||
|
res = await client.chain(calls, { allowErrors: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (!sendOutcomeUnknown(err)) throw err;
|
||||||
|
// No answer is not a no: find out what the server did with it.
|
||||||
|
let found: Awaited<ReturnType<typeof traceSend>>;
|
||||||
|
try {
|
||||||
|
found = await traceSend(accountId, messageId, true);
|
||||||
|
} catch {
|
||||||
|
throw new SendOutcomeUnknown(translate("Couldn't confirm whether this message was sent. Check Sent before sending it again."));
|
||||||
|
}
|
||||||
|
if (found.trace === "sent") {
|
||||||
|
void mail.loadMailboxes();
|
||||||
|
void mail.refreshList();
|
||||||
|
return { held: false };
|
||||||
|
}
|
||||||
|
// Created but never submitted: take it out of Sent, then report the failure.
|
||||||
|
if (found.trace === "orphan") void client.call("Email/set", { accountId, destroy: found.emailIds });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
const e = res.get("e")?.[0] as unknown as SetResponse<Email> & { __error?: { type: string; description?: string } };
|
const e = res.get("e")?.[0] as unknown as SetResponse<Email> & { __error?: { type: string; description?: string } };
|
||||||
if (e.__error) throw new Error(setErrorMessage(e.__error));
|
if (e.__error) throw new Error(setErrorMessage(e.__error));
|
||||||
if (e.notCreated?.m) throw new Error(setErrorMessage(e.notCreated.m));
|
if (e.notCreated?.m) throw new Error(setErrorMessage(e.notCreated.m));
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { create } from "zustand";
|
|||||||
import { accountKey, loadRaw, saveJson } from "@/lib/storage";
|
import { accountKey, loadRaw, saveJson } from "@/lib/storage";
|
||||||
import { CAP, chunk, client, JmapMethodError, setErrorMessage } from "@/jmap/client";
|
import { CAP, chunk, client, JmapMethodError, setErrorMessage } from "@/jmap/client";
|
||||||
import type { AddressBook, ChangesResponse, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetError, SetResponse } from "@/jmap/types";
|
import type { AddressBook, ChangesResponse, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetError, SetResponse } from "@/jmap/types";
|
||||||
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
|
import { contactDisplayName, contactEmails, contactFromAddress, sortKey } from "@/lib/contacts";
|
||||||
|
import { uniqueAddresses } from "@/lib/address";
|
||||||
import { parseLdif, uidFromDn } from "@/lib/ldif";
|
import { parseLdif, uidFromDn } from "@/lib/ldif";
|
||||||
import { cardFromLdif } from "@/lib/mozillaAb";
|
import { cardFromLdif } from "@/lib/mozillaAb";
|
||||||
import { useSettings } from "./settings";
|
import { useSettings } from "./settings";
|
||||||
|
import { t as translate } from "@/lib/i18n";
|
||||||
import { useSession } from "./session";
|
import { useSession } from "./session";
|
||||||
import { useMail } from "./mail";
|
import { useMail } from "./mail";
|
||||||
|
|
||||||
@@ -225,6 +227,8 @@ interface ContactsState {
|
|||||||
*/
|
*/
|
||||||
emptyBook(bookId: Id): Promise<{ destroyed: number; unfiled: number; refused?: SetError }>;
|
emptyBook(bookId: Id): Promise<{ destroyed: number; unfiled: number; refused?: SetError }>;
|
||||||
createBook(name: string): Promise<Id>;
|
createBook(name: string): Promise<Id>;
|
||||||
|
/** Save the addresses that are not on any contact yet, in the Collected address book. */
|
||||||
|
collectRecipients(addrs: EmailAddress[], own: string[]): Promise<number>;
|
||||||
updateBook(id: Id, patch: Partial<AddressBook>): Promise<void>;
|
updateBook(id: Id, patch: Partial<AddressBook>): Promise<void>;
|
||||||
destroyBook(id: Id): Promise<void>;
|
destroyBook(id: Id): Promise<void>;
|
||||||
/** Import vCards, updating any whose UID this book already holds rather than duplicating it. */
|
/** Import vCards, updating any whose UID this book already holds rather than duplicating it. */
|
||||||
@@ -616,6 +620,28 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
|||||||
return { destroyed: gone.length, unfiled, refused };
|
return { destroyed: gone.length, unfiled, refused };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async collectRecipients(addrs, own) {
|
||||||
|
if (!get().available || !get().accountId) return 0;
|
||||||
|
// Every card has to be known, or someone already a contact gets a second card.
|
||||||
|
if (!get().loaded) await get().loadAll();
|
||||||
|
const ownSet = new Set(own.map((e) => e.toLowerCase()));
|
||||||
|
const fresh = uniqueAddresses(addrs).filter((a) => a.email && !ownSet.has(a.email.toLowerCase()) && !get().lookupByEmail(a.email));
|
||||||
|
if (!fresh.length) return 0;
|
||||||
|
const accountId = get().accountId!;
|
||||||
|
const { settings, update } = useSettings.getState();
|
||||||
|
let bookId = settings.collectedBookId && get().books[settings.collectedBookId] ? settings.collectedBookId : null;
|
||||||
|
if (!bookId) {
|
||||||
|
bookId = await get().createBook(translate("Collected"));
|
||||||
|
update({ collectedBookId: bookId });
|
||||||
|
}
|
||||||
|
const create = Object.fromEntries(
|
||||||
|
fresh.map((a, i) => [`c${i}`, { "@type": "Card", version: "1.0", uid: crypto.randomUUID(), kind: "individual", ...contactFromAddress(a), addressBookIds: { [bookId!]: true } }]),
|
||||||
|
);
|
||||||
|
const res = await client.call<SetResponse<ContactCard>>("ContactCard/set", { accountId, create });
|
||||||
|
await get().syncCards();
|
||||||
|
return Object.keys(res.created ?? {}).length;
|
||||||
|
},
|
||||||
|
|
||||||
async createBook(name) {
|
async createBook(name) {
|
||||||
const accountId = get().accountId!;
|
const accountId = get().accountId!;
|
||||||
const res = await client.call<SetResponse<AddressBook>>("AddressBook/set", { accountId, create: { b: { name } } });
|
const res = await client.call<SetResponse<AddressBook>>("AddressBook/set", { accountId, create: { b: { name } } });
|
||||||
@@ -793,14 +819,29 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
|||||||
seen.add(k);
|
seen.add(k);
|
||||||
out.push(s);
|
out.push(s);
|
||||||
};
|
};
|
||||||
const score = (name: string | null, email: string): number => {
|
/*
|
||||||
|
* Words in any order: "jane smi" finds "Smith, Jane", and a nickname or the
|
||||||
|
* organization counts as much as the name. Each word typed has to start a
|
||||||
|
* word of the person's name, nickname, organization or address.
|
||||||
|
*/
|
||||||
|
const tokens = q.split(/\s+/).filter(Boolean);
|
||||||
|
const wordsOf = (parts: Array<string | null | undefined>) =>
|
||||||
|
parts.flatMap((p) => (p ?? "").toLowerCase().split(/[\s,.;:@_+()<>"'-]+/)).filter(Boolean);
|
||||||
|
const score = (name: string | null, email: string, extra: Array<string | null | undefined> = []): number => {
|
||||||
const n = (name ?? "").toLowerCase();
|
const n = (name ?? "").toLowerCase();
|
||||||
const e = email.toLowerCase();
|
const e = email.toLowerCase();
|
||||||
if (e.startsWith(q) || n.startsWith(q)) return 0;
|
if (e.startsWith(q) || n.startsWith(q)) return 0;
|
||||||
if (n.split(/\s+/).some((w) => w.startsWith(q))) return 1;
|
const words = wordsOf([name, email, ...extra]);
|
||||||
|
if (tokens.every((t) => words.some((w) => w.startsWith(t)))) return 1;
|
||||||
if (e.includes(q) || n.includes(q)) return 2;
|
if (e.includes(q) || n.includes(q)) return 2;
|
||||||
return 99;
|
return 99;
|
||||||
};
|
};
|
||||||
|
// Someone written to lately ranks a little above someone not, within the same kind of match.
|
||||||
|
const recentRank = new Map(st.recent.map((r, i) => [r.email.toLowerCase(), i] as const));
|
||||||
|
const recency = (email: string) => {
|
||||||
|
const i = recentRank.get(email.toLowerCase());
|
||||||
|
return i === undefined ? 0 : -0.3 * (1 - i / Math.max(1, st.recent.length));
|
||||||
|
};
|
||||||
const candidates: Array<Suggestion & { score: number }> = [];
|
const candidates: Array<Suggestion & { score: number }> = [];
|
||||||
// A shared address book is only useful if it answers when you are writing
|
// A shared address book is only useful if it answers when you are writing
|
||||||
// to someone in it, so its cards are offered alongside the reader's own.
|
// to someone in it, so its cards are offered alongside the reader's own.
|
||||||
@@ -808,19 +849,20 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
|||||||
const own = Object.values(st.cards).map((c) => ({ c, penalty: 0 }));
|
const own = Object.values(st.cards).map((c) => ({ c, penalty: 0 }));
|
||||||
const shared = Object.values(st.sharedCards).map((c) => ({ c, penalty: 0.5 }));
|
const shared = Object.values(st.sharedCards).map((c) => ({ c, penalty: 0.5 }));
|
||||||
for (const { c, penalty } of [...own, ...shared]) {
|
for (const { c, penalty } of [...own, ...shared]) {
|
||||||
|
const extra = [...Object.values(c.nicknames ?? {}).map((x) => x.name), ...Object.values(c.organizations ?? {}).map((x) => x.name)];
|
||||||
for (const a of contactEmails(c)) {
|
for (const a of contactEmails(c)) {
|
||||||
const sc = score(a.name, a.email);
|
const sc = score(a.name, a.email, extra);
|
||||||
if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc + penalty });
|
if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc + penalty + recency(a.email) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const p of st.principals) {
|
for (const p of st.principals) {
|
||||||
if (!p.email) continue;
|
if (!p.email) continue;
|
||||||
const sc = score(p.name, p.email);
|
const sc = score(p.name, p.email);
|
||||||
if (sc < 99) candidates.push({ name: p.name, email: p.email, source: "gal", score: sc + 0.5 });
|
if (sc < 99) candidates.push({ name: p.name, email: p.email, source: "gal", score: sc + 0.5 + recency(p.email) });
|
||||||
}
|
}
|
||||||
for (const r of st.recent) {
|
for (const r of st.recent) {
|
||||||
const sc = score(r.name, r.email);
|
const sc = score(r.name, r.email);
|
||||||
if (sc < 99) candidates.push({ name: r.name, email: r.email, source: "recent", score: sc + 0.25 });
|
if (sc < 99) candidates.push({ name: r.name, email: r.email, source: "recent", score: sc + 0.25 + recency(r.email) });
|
||||||
}
|
}
|
||||||
candidates.sort((a, b) => a.score - b.score || (a.name ?? a.email).localeCompare(b.name ?? b.email));
|
candidates.sort((a, b) => a.score - b.score || (a.name ?? a.email).localeCompare(b.name ?? b.email));
|
||||||
for (const c of candidates) {
|
for (const c of candidates) {
|
||||||
|
|||||||
@@ -206,6 +206,15 @@ export interface Settings {
|
|||||||
* dates nobody put there is a surprise rather than a feature.
|
* dates nobody put there is a surprise rather than a feature.
|
||||||
*/
|
*/
|
||||||
birthdayCalendar: boolean;
|
birthdayCalendar: boolean;
|
||||||
|
/**
|
||||||
|
* Save everyone written to who is not a contact yet, in an address book of
|
||||||
|
* its own, so they are suggested on every device rather than only in the
|
||||||
|
* browser that sent the message. On by default, as mail clients do; the
|
||||||
|
* address book can be emptied or deleted like any other.
|
||||||
|
*/
|
||||||
|
collectRecipients: boolean;
|
||||||
|
/** The address book collected recipients go to, once there is one. */
|
||||||
|
collectedBookId: string | null;
|
||||||
/**
|
/**
|
||||||
* Calendars subscribed to by URL. The subscription is the setting; the
|
* Calendars subscribed to by URL. The subscription is the setting; the
|
||||||
* events themselves are fetched on demand and never stored, so this follows
|
* events themselves are fetched on demand and never stored, so this follows
|
||||||
@@ -361,6 +370,8 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
timeZone: null,
|
timeZone: null,
|
||||||
labelsSidebar: true,
|
labelsSidebar: true,
|
||||||
birthdayCalendar: false,
|
birthdayCalendar: false,
|
||||||
|
collectRecipients: true,
|
||||||
|
collectedBookId: null,
|
||||||
icalSubscriptions: [],
|
icalSubscriptions: [],
|
||||||
listSortPreset: "newest",
|
listSortPreset: "newest",
|
||||||
listSortLevels: [],
|
listSortLevels: [],
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// Read off disk: `?raw` comes back empty for a stylesheet under the test runner.
|
||||||
|
const css = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../app.css"), "utf8");
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The app rail, the collapsed sidebar and the raised surfaces are all CSS, and
|
||||||
|
* jsdom does no layout, so these pin the rules themselves. Contrast for the
|
||||||
|
* pill accent was measured in a browser across every palette, accent and
|
||||||
|
* mode; the shape of that rule is pinned here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const rule = (selector: string) => {
|
||||||
|
const i = css.indexOf(selector + " {");
|
||||||
|
expect(i, `no rule for ${selector}`).toBeGreaterThan(-1);
|
||||||
|
return css.slice(i, css.indexOf("}", i));
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("app layout CSS", () => {
|
||||||
|
it("gives the rail its own column ahead of the sidebar, collapsed or not", () => {
|
||||||
|
expect(rule(".app-body.has-rail")).toMatch(/grid-template-columns:\s*56px var\(--sidebar-w\)/);
|
||||||
|
expect(rule(".app-body.has-rail.collapsed")).toMatch(/grid-template-columns:\s*56px var\(--sidebar-w-collapsed\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides a sidebar with no icon-only form instead of crushing it", () => {
|
||||||
|
expect(rule(".app-body.has-rail.sidebar-hidden")).toMatch(/grid-template-columns:\s*56px 0 /);
|
||||||
|
expect(rule(".app-body.sidebar-hidden > .sidebar")).toMatch(/visibility:\s*hidden/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides section headings when collapsed, over the sidebar's own display rule", () => {
|
||||||
|
// `.sidebar .nav-section { display: flex }` comes later at the same weight;
|
||||||
|
// the collapsed rule has to outweigh it or "FOLDERS" is drawn cut off.
|
||||||
|
expect(css).toMatch(/\.app-body\.collapsed \.sidebar \.nav-section[^{]*\{\s*display:\s*none/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("darkens the pill accent in light themes and lightens it in dark ones", () => {
|
||||||
|
expect(css).toMatch(/:root \{ --pill-accent: color-mix\(in srgb, var\(--accent\) \d+%, black\); \}/);
|
||||||
|
expect(css).toMatch(/:root\[data-theme="dark"\] \{ --pill-accent: color-mix\(in srgb, var\(--accent\) \d+%, white\); \}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a color the reader picked win over the role tint", () => {
|
||||||
|
for (const role of ["inbox", "drafts", "sent", "archive", "junk", "trash", "scheduled"]) {
|
||||||
|
expect(css).toContain(`.folder-icon[data-role="${role}"]:not([style*="--folder-color"]) svg`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+90
-1
@@ -1316,7 +1316,7 @@ a.menu-item:hover { color: var(--fg); }
|
|||||||
.collapsed .compose-btn span { display: none; }
|
.collapsed .compose-btn span { display: none; }
|
||||||
.nav-section { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px 4px; font-size: .75em; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--fg-faint); }
|
.nav-section { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px 4px; font-size: .75em; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--fg-faint); }
|
||||||
.nav-section .icon-btn { width: 24px; height: 24px; }
|
.nav-section .icon-btn { width: 24px; height: 24px; }
|
||||||
.collapsed .nav-section { display: none; }
|
.collapsed .nav-section, .app-body.collapsed .sidebar .nav-section { display: none; }
|
||||||
.nav-item { position: relative; display: flex; align-items: center; gap: 12px; height: 36px; padding: 0 12px; border-radius: 0 999px 999px 0; margin-right: 8px; color: var(--fg); text-decoration: none; white-space: nowrap; transition: background .12s; user-select: none; font-weight: 450; }
|
.nav-item { position: relative; display: flex; align-items: center; gap: 12px; height: 36px; padding: 0 12px; border-radius: 0 999px 999px 0; margin-right: 8px; color: var(--fg); text-decoration: none; white-space: nowrap; transition: background .12s; user-select: none; font-weight: 450; }
|
||||||
.nav-item:hover { background: var(--bg-hover); }
|
.nav-item:hover { background: var(--bg-hover); }
|
||||||
.nav-item.unread .nav-label, .nav-item.unread .nav-count { font-weight: 700; color: var(--fg); }
|
.nav-item.unread .nav-label, .nav-item.unread .nav-count { font-weight: 700; color: var(--fg); }
|
||||||
@@ -1383,6 +1383,82 @@ a.menu-item:hover { color: var(--fg); }
|
|||||||
.collapsed .nav-item .nav-dot { position: absolute; top: 6px; right: 6px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
|
.collapsed .nav-item .nav-dot { position: absolute; top: 6px; right: 6px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
|
||||||
.nav-label-color { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 auto; }
|
.nav-label-color { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 auto; }
|
||||||
/* Outlook-style module bar at the bottom of the sidebar */
|
/* Outlook-style module bar at the bottom of the sidebar */
|
||||||
|
/* App rail. A fixed column left of the sidebar on wide screens; phones keep the tab bar. */
|
||||||
|
.app-body.has-rail { grid-template-columns: 56px var(--sidebar-w) minmax(0, 1fr); }
|
||||||
|
.app-body.has-rail.collapsed { grid-template-columns: 56px var(--sidebar-w-collapsed) minmax(0, 1fr); }
|
||||||
|
.app-body.has-rail > .sidebar-splitter { left: calc(56px + var(--sidebar-w) - 3px); }
|
||||||
|
/* Only Mail's folder list has an icon-only form. Calendar, Contacts, Files, Settings and Admin
|
||||||
|
hide their sidebar outright when collapsed; the rail still navigates and its toggle brings it back. */
|
||||||
|
.app-body.has-rail.sidebar-hidden { grid-template-columns: 56px 0 minmax(0, 1fr); }
|
||||||
|
.app-body.sidebar-hidden > .sidebar { visibility: hidden; padding: 0; min-width: 0; }
|
||||||
|
.app-rail { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 0 12px; border-right: 1px solid var(--border); min-height: 0; }
|
||||||
|
.app-rail-spacer { flex: 1; }
|
||||||
|
.rail-toggle { background: none; border: 0; cursor: pointer; margin-top: 4px; }
|
||||||
|
.rail-link { position: relative; display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 12px; color: var(--fg-muted); transition: background .12s, color .12s; }
|
||||||
|
.rail-link:hover { background: var(--bg-hover); color: var(--fg); }
|
||||||
|
.rail-link.active { background: var(--accent-soft); color: var(--accent-soft-fg); }
|
||||||
|
.rail-badge { position: absolute; top: -3px; right: -5px; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: var(--pill-accent); color: var(--accent-fg); font-size: 10.5px; font-weight: 700; line-height: 18px; text-align: center; box-shadow: 0 0 0 2px var(--bg); }
|
||||||
|
|
||||||
|
/* Pill counts. Accent-filled when the folder has unread mail, neutral for totals (Drafts, Scheduled). */
|
||||||
|
.nav-item .nav-count { display: inline-flex; align-items: center; justify-content: center; min-width: 22px; height: 20px; padding: 0 7px; border-radius: 999px; background: var(--bg-active); color: var(--fg-muted); font-size: .74em; font-weight: 650; font-variant-numeric: tabular-nums; }
|
||||||
|
/* The accent as a fill behind small text (pills, the rail badge, Compose). The plain accent is
|
||||||
|
a mid tone that white text does not read on in light themes, so it is darkened there and
|
||||||
|
lightened a touch in dark ones: at least 4.5:1 in all 12 palettes x 6 accents x 2 modes,
|
||||||
|
measured 2026-10-05 (lowest 4.83). */
|
||||||
|
:root { --pill-accent: color-mix(in srgb, var(--accent) 70%, black); }
|
||||||
|
:root[data-theme="dark"] { --pill-accent: color-mix(in srgb, var(--accent) 88%, white); }
|
||||||
|
.nav-item.unread .nav-count { background: var(--pill-accent); color: var(--accent-fg); font-weight: 700; }
|
||||||
|
.nav-item.active .nav-count, .nav-item.active.unread .nav-count { background: var(--pill-accent); color: var(--accent-fg); box-shadow: 0 1px 2px rgb(0 0 0 / .25); }
|
||||||
|
.collapsed .nav-item .nav-count { display: none; }
|
||||||
|
|
||||||
|
/* Raised surfaces. A top-lit sheen, a 1px inner highlight on top and shade below, and a small drop,
|
||||||
|
so buttons and the current selection read as pieces standing off the page. Tuned per theme lightness. */
|
||||||
|
:root {
|
||||||
|
--raise-sheen: linear-gradient(to bottom, rgb(255 255 255 / .30), rgb(255 255 255 / .04) 50%, rgb(0 0 0 / .06));
|
||||||
|
--raise-sheen-strong: linear-gradient(to bottom, rgb(255 255 255 / .32), rgb(255 255 255 / .06) 50%, rgb(0 0 0 / .14));
|
||||||
|
--raise-hi: rgb(255 255 255 / .75);
|
||||||
|
--raise-lo: rgb(0 0 0 / .12);
|
||||||
|
--raise-drop: 0 1px 2px rgb(0 0 0 / .14), 0 3px 8px rgb(0 0 0 / .09);
|
||||||
|
--raise-press: inset 0 1px 2px rgb(0 0 0 / .14);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--raise-sheen: linear-gradient(to bottom, rgb(255 255 255 / .14), rgb(255 255 255 / .02) 50%, rgb(0 0 0 / .16));
|
||||||
|
--raise-sheen-strong: linear-gradient(to bottom, rgb(255 255 255 / .30), rgb(255 255 255 / .06) 50%, rgb(0 0 0 / .22));
|
||||||
|
--raise-hi: rgb(255 255 255 / .16);
|
||||||
|
--raise-lo: rgb(0 0 0 / .45);
|
||||||
|
--raise-drop: 0 1px 2px rgb(0 0 0 / .55), 0 3px 10px rgb(0 0 0 / .32);
|
||||||
|
--raise-press: inset 0 1px 3px rgb(0 0 0 / .45);
|
||||||
|
}
|
||||||
|
.btn, .compose-btn, .rail-link.active, .nav-item.active, .module-link.active, .segmented button.active {
|
||||||
|
background-image: var(--raise-sheen);
|
||||||
|
box-shadow: inset 0 1px 0 var(--raise-hi), inset 0 -1px 0 var(--raise-lo), var(--raise-drop);
|
||||||
|
}
|
||||||
|
.btn-primary, .rail-badge { background-image: var(--raise-sheen-strong); }
|
||||||
|
.compose-btn, .compose-btn:hover { background-color: var(--pill-accent); color: var(--accent-fg); background-image: var(--raise-sheen-strong); }
|
||||||
|
.compose-btn svg { color: inherit; }
|
||||||
|
.compose-btn, .rail-link.active { box-shadow: inset 0 1px 0 rgb(255 255 255 / .28), inset 0 -1px 0 rgb(0 0 0 / .22), var(--raise-drop); }
|
||||||
|
.compose-btn:hover { box-shadow: inset 0 1px 0 rgb(255 255 255 / .28), inset 0 -1px 0 rgb(0 0 0 / .22), var(--shadow-2); filter: brightness(1.05); }
|
||||||
|
.rail-link.active, .rail-link.active:hover { background-color: var(--accent); color: var(--accent-fg); background-image: var(--raise-sheen-strong); }
|
||||||
|
.btn-primary { box-shadow: inset 0 1px 0 rgb(255 255 255 / .22), inset 0 -1px 0 rgb(0 0 0 / .18), var(--raise-drop); }
|
||||||
|
.btn:active, .compose-btn:active, .rail-link:active, .nav-item:active, .module-link:active, .segmented button:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
box-shadow: var(--raise-press);
|
||||||
|
background-image: none;
|
||||||
|
}
|
||||||
|
/* The segmented control clips its children; the raise sits inside its border. */
|
||||||
|
.segmented button.active { box-shadow: inset 0 1px 0 var(--raise-hi), inset 0 -1px 0 var(--raise-lo); }
|
||||||
|
|
||||||
|
/* Tinted role-folder icons. A color the reader picked for a folder still wins (style attribute, fill rule above). */
|
||||||
|
:root { --role-inbox: #2f6fe4; --role-drafts: #8a5cd6; --role-sent: #138a6c; --role-archive: #976214; --role-junk: #b8501c; --role-trash: #c93c4b; --role-scheduled: #0f8aa6; }
|
||||||
|
:root[data-theme="dark"] { --role-inbox: #6ea2ff; --role-drafts: #b796f5; --role-sent: #4fcfa6; --role-archive: #e5b45a; --role-junk: #f2925f; --role-trash: #f07a87; --role-scheduled: #4fc3dc; }
|
||||||
|
.folder-row .folder-icon[data-role="inbox"]:not([style*="--folder-color"]) svg { color: var(--role-inbox); }
|
||||||
|
.folder-row .folder-icon[data-role="drafts"]:not([style*="--folder-color"]) svg { color: var(--role-drafts); }
|
||||||
|
.folder-row .folder-icon[data-role="sent"]:not([style*="--folder-color"]) svg { color: var(--role-sent); }
|
||||||
|
.folder-row .folder-icon[data-role="archive"]:not([style*="--folder-color"]) svg { color: var(--role-archive); }
|
||||||
|
.folder-row .folder-icon[data-role="junk"]:not([style*="--folder-color"]) svg { color: var(--role-junk); }
|
||||||
|
.folder-row .folder-icon[data-role="trash"]:not([style*="--folder-color"]) svg { color: var(--role-trash); }
|
||||||
|
.folder-row .folder-icon[data-role="scheduled"]:not([style*="--folder-color"]) svg { color: var(--role-scheduled); }
|
||||||
|
|
||||||
.module-bar { display: flex; align-items: stretch; justify-content: space-around; gap: 2px; padding: 6px 4px 4px; border-top: 1px solid var(--border); flex: 0 0 auto; }
|
.module-bar { display: flex; align-items: stretch; justify-content: space-around; gap: 2px; padding: 6px 4px 4px; border-top: 1px solid var(--border); flex: 0 0 auto; }
|
||||||
.module-link { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; flex: 1; min-width: 0; padding: 6px 2px; border-radius: var(--radius-sm); color: var(--fg-muted); text-decoration: none; font-size: 11px; font-weight: 500; transition: background .12s, color .12s; }
|
.module-link { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; flex: 1; min-width: 0; padding: 6px 2px; border-radius: var(--radius-sm); color: var(--fg-muted); text-decoration: none; font-size: 11px; font-weight: 500; transition: background .12s, color .12s; }
|
||||||
.module-link:hover { background: var(--bg-hover); color: var(--fg); }
|
.module-link:hover { background: var(--bg-hover); color: var(--fg); }
|
||||||
@@ -1475,6 +1551,11 @@ a.menu-item:hover { color: var(--fg); }
|
|||||||
.mail-list.two-line .msg-row .msg-from { width: auto; flex: 1; }
|
.mail-list.two-line .msg-row .msg-from { width: auto; flex: 1; }
|
||||||
.mail-list.two-line .msg-row .msg-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
.mail-list.two-line .msg-row .msg-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||||
.mail-list.two-line .msg-row .msg-line1 { display: flex; align-items: center; gap: 8px; }
|
.mail-list.two-line .msg-row .msg-line1 { display: flex; align-items: center; gap: 8px; }
|
||||||
|
/* Labels share the sender's line (#35). The sender gives way first, then the
|
||||||
|
labels, each of which truncates rather than pushing the date off the row. */
|
||||||
|
.mail-list.two-line .msg-row .msg-line1 .msg-from { flex: 0 1 auto; min-width: 3em; }
|
||||||
|
.mail-list.two-line .msg-row .msg-line1 .msg-labels { flex: 0 1 auto; min-width: 0; max-width: 55%; overflow: hidden; }
|
||||||
|
.mail-list.two-line .msg-row .msg-line1 .msg-labels .tag { flex: 0 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; display: block; line-height: 18px; }
|
||||||
.mail-list.two-line .msg-row .msg-main { display: flex; gap: 6px; }
|
.mail-list.two-line .msg-row .msg-main { display: flex; gap: 6px; }
|
||||||
.mail-list.two-line .msg-row .msg-subject { flex: 0 1 auto; }
|
.mail-list.two-line .msg-row .msg-subject { flex: 0 1 auto; }
|
||||||
.mail-list.two-line .msg-row .msg-preview { flex: 1; }
|
.mail-list.two-line .msg-row .msg-preview { flex: 1; }
|
||||||
@@ -1622,6 +1703,14 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
|
|||||||
.editor-area:empty::before, .editor-area[data-empty="true"]::before { content: attr(data-placeholder); color: var(--fg-faint); pointer-events: none; position: absolute; }
|
.editor-area:empty::before, .editor-area[data-empty="true"]::before { content: attr(data-placeholder); color: var(--fg-faint); pointer-events: none; position: absolute; }
|
||||||
.editor-area blockquote { margin: 0 0 0 .8ex; border-left: 2px solid var(--border-strong); padding-left: 1ex; color: var(--fg-muted); }
|
.editor-area blockquote { margin: 0 0 0 .8ex; border-left: 2px solid var(--border-strong); padding-left: 1ex; color: var(--fg-muted); }
|
||||||
.editor-area img { max-width: 100%; height: auto; }
|
.editor-area img { max-width: 100%; height: auto; }
|
||||||
|
/* Image resizing (Gitea issue #26): drawn over the editor, never inside it, so none of it is sent. */
|
||||||
|
.img-resize-layer { position: absolute; pointer-events: none; overflow: hidden; z-index: 5; }
|
||||||
|
.img-resize-frame { position: absolute; outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
|
||||||
|
.img-resize-handle { position: absolute; right: -7px; bottom: -7px; width: 14px; height: 14px; background: var(--accent); border: 2px solid var(--bg-elev); border-radius: 3px; cursor: nwse-resize; pointer-events: auto; touch-action: none; }
|
||||||
|
.img-resize-bar { position: absolute; display: flex; gap: 4px; padding: 3px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius-sm); box-shadow: var(--shadow-2); pointer-events: auto; }
|
||||||
|
.img-resize-bar .btn-sm { height: 26px; padding: 0 8px; }
|
||||||
|
.img-resize-bar .btn[aria-pressed="true"] { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
||||||
|
@media (pointer: coarse) { .img-resize-handle { width: 24px; height: 24px; right: -12px; bottom: -12px; border-radius: 50%; } }
|
||||||
.editor-area a { color: var(--link); }
|
.editor-area a { color: var(--link); }
|
||||||
.editor-area .ihm-signature { color: var(--fg-muted); }
|
.editor-area .ihm-signature { color: var(--fg-muted); }
|
||||||
.editor-area pre { font-family: var(--font-mono); background: var(--bg-sunken); padding: 8px; border-radius: 6px; overflow: auto; }
|
.editor-area pre { font-family: var(--font-mono); background: var(--bg-sunken); padding: 8px; border-radius: 6px; overflow: auto; }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react";
|
import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { Calendar, Check, ChevronsUpDown, FolderOpen, Globe, HelpCircle, Lock, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
|
import { Calendar, Check, ChevronsUpDown, FolderOpen, Globe, HelpCircle, Lock, LogOut, Mail, Menu as MenuIcon, Moon, PanelLeftClose, PanelLeftOpen, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
|
||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
import { DEFAULT_APP_NAME, brandImage } from "@/lib/brand";
|
import { DEFAULT_APP_NAME, brandImage } from "@/lib/brand";
|
||||||
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
|
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
|
||||||
@@ -52,6 +52,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
const [location, navigate] = useLocation();
|
const [location, navigate] = useLocation();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const collapsed = useSettings((s) => s.settings.sidebarCollapsed);
|
const collapsed = useSettings((s) => s.settings.sidebarCollapsed);
|
||||||
|
const inboxUnread = useMail((s) => Object.values(s.mailboxes).find((m) => m.role === "inbox")?.unreadEmails ?? 0);
|
||||||
const sidebarWidth = useSettings((s) => s.settings.sidebarWidth);
|
const sidebarWidth = useSettings((s) => s.settings.sidebarWidth);
|
||||||
const update = useSettings((s) => s.update);
|
const update = useSettings((s) => s.update);
|
||||||
/*
|
/*
|
||||||
@@ -166,9 +167,12 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<div className={`app ${viewing ? "delegated" : ""}`}>
|
<div className={`app ${viewing ? "delegated" : ""}`}>
|
||||||
<DelegatedBar />
|
<DelegatedBar />
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<button className="icon-btn" aria-label={t("Menu")} onClick={() => (isMobile ? setDrawer((d) => !d) : update({ sidebarCollapsed: !collapsed }))}>
|
{/* On a wide screen the folder list is toggled from the bottom of the rail; phones keep the menu button for the drawer. */}
|
||||||
|
{isMobile && (
|
||||||
|
<button className="icon-btn" aria-label={t("Menu")} onClick={() => setDrawer((d) => !d)}>
|
||||||
<MenuIcon size={22} />
|
<MenuIcon size={22} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<Link href="/mail" className={`brand ${viewing ? "locked" : ""}`}>
|
<Link href="/mail" className={`brand ${viewing ? "locked" : ""}`}>
|
||||||
<img src={brandImage(appName === DEFAULT_APP_NAME ? "/img/inbuxa-mark.png" : "/img/logo.png")} alt="" />
|
<img src={brandImage(appName === DEFAULT_APP_NAME ? "/img/inbuxa-mark.png" : "/img/logo.png")} alt="" />
|
||||||
{viewing && <Lock size={18} className="brand-lock" aria-label={t("Locked account")} />}
|
{viewing && <Lock size={18} className="brand-lock" aria-label={t("Locked account")} />}
|
||||||
@@ -193,9 +197,12 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<HelpCircle size={21} />
|
<HelpCircle size={21} />
|
||||||
</button>
|
</button>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
{/* On a wide screen the rail carries Settings; phones have no rail, so they keep this one. */}
|
||||||
|
{isMobile && (
|
||||||
<Link href="/settings" className={`icon-btn ${section === "settings" ? "active" : ""}`} aria-label={t("Settings")} title={t("Settings")}>
|
<Link href="/settings" className={`icon-btn ${section === "settings" ? "active" : ""}`} aria-label={t("Settings")} title={t("Settings")}>
|
||||||
<Settings size={21} />
|
<Settings size={21} />
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
<button className="icon-btn" style={{ width: "auto", padding: "0 2px", borderRadius: 999 }} onClick={acctMenu.open} aria-label={t("Account")}>
|
<button className="icon-btn" style={{ width: "auto", padding: "0 2px", borderRadius: 999 }} onClick={acctMenu.open} aria-label={t("Account")}>
|
||||||
<Avatar who={{ name: session?.username, email: session?.username }} size="sm" />
|
<Avatar who={{ name: session?.username, email: session?.username }} size="sm" />
|
||||||
</button>
|
</button>
|
||||||
@@ -263,10 +270,30 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`app-body ${collapsed && !isMobile ? "collapsed" : ""} ${liveSidebarWidth != null ? "resizing" : ""}`}
|
className={`app-body ${!isMobile ? "has-rail" : ""} ${collapsed && !isMobile ? "collapsed" : ""} ${collapsed && !isMobile && section !== "mail" && section !== "search" ? "sidebar-hidden" : ""} ${liveSidebarWidth != null ? "resizing" : ""}`}
|
||||||
style={shownSidebarWidth != null && !isMobile ? ({ "--sidebar-w": `${shownSidebarWidth}px` } as React.CSSProperties) : undefined}
|
style={shownSidebarWidth != null && !isMobile ? ({ "--sidebar-w": `${shownSidebarWidth}px` } as React.CSSProperties) : undefined}
|
||||||
>
|
>
|
||||||
<div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} />
|
<div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} />
|
||||||
|
{!isMobile && (
|
||||||
|
<nav className="app-rail" aria-label={t("Go to")}>
|
||||||
|
<RailLink href="/mail" icon={<Mail size={20} />} label={t("Mail")} active={section === "mail" || section === "search"} badge={inboxUnread} />
|
||||||
|
<RailLink href="/calendar" icon={<Calendar size={20} />} label={t("Calendar")} active={section === "calendar"} />
|
||||||
|
<RailLink href="/contacts" icon={<Users size={20} />} label={t("Contacts")} active={section === "contacts"} />
|
||||||
|
<RailLink href="/files" icon={<FolderOpen size={20} />} label={t("Files")} active={section === "files"} />
|
||||||
|
<span className="app-rail-spacer" />
|
||||||
|
<RailLink href="/settings" icon={<Settings size={20} />} label={t("Settings")} active={section === "settings"} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rail-link rail-toggle"
|
||||||
|
aria-label={collapsed ? t("Show folder list") : t("Hide folder list")}
|
||||||
|
title={collapsed ? t("Show folder list") : t("Hide folder list")}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
onClick={() => update({ sidebarCollapsed: !collapsed })}
|
||||||
|
>
|
||||||
|
{collapsed ? <PanelLeftOpen size={20} /> : <PanelLeftClose size={20} />}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
<aside ref={sidebarRef} className={`sidebar ${drawer ? "open" : ""}`}>
|
<aside ref={sidebarRef} className={`sidebar ${drawer ? "open" : ""}`}>
|
||||||
{/*
|
{/*
|
||||||
The way back out.
|
The way back out.
|
||||||
@@ -316,12 +343,12 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
{section === "admin" && <AdminNav />}
|
{section === "admin" && <AdminNav />}
|
||||||
</div>
|
</div>
|
||||||
{(section === "mail" || section === "search") && <QuotaBar />}
|
{(section === "mail" || section === "search") && <QuotaBar />}
|
||||||
<nav className="module-bar" aria-label={t("Go to")}>
|
{isMobile && <nav className="module-bar" aria-label={t("Go to")}>
|
||||||
<ModuleLink href="/mail" icon={<Mail size={20} />} label={t("Mail")} active={section === "mail" || section === "search"} />
|
<ModuleLink href="/mail" icon={<Mail size={20} />} label={t("Mail")} active={section === "mail" || section === "search"} />
|
||||||
<ModuleLink href="/calendar" icon={<Calendar size={20} />} label={t("Calendar")} active={section === "calendar"} />
|
<ModuleLink href="/calendar" icon={<Calendar size={20} />} label={t("Calendar")} active={section === "calendar"} />
|
||||||
<ModuleLink href="/contacts" icon={<Users size={20} />} label={t("Contacts")} active={section === "contacts"} />
|
<ModuleLink href="/contacts" icon={<Users size={20} />} label={t("Contacts")} active={section === "contacts"} />
|
||||||
<ModuleLink href="/files" icon={<FolderOpen size={20} />} label={t("Files")} active={section === "files"} />
|
<ModuleLink href="/files" icon={<FolderOpen size={20} />} label={t("Files")} active={section === "files"} />
|
||||||
</nav>
|
</nav>}
|
||||||
</aside>
|
</aside>
|
||||||
{/* Not on a phone, where the sidebar is a drawer over the page, and not
|
{/* Not on a phone, where the sidebar is a drawer over the page, and not
|
||||||
while collapsed to icons, where there is no width to choose. */}
|
while collapsed to icons, where there is no width to choose. */}
|
||||||
@@ -406,6 +433,16 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The app rail down the left edge, which replaces the module bar on a wide screen. */
|
||||||
|
function RailLink({ href, icon, label, active, badge = 0 }: { href: string; icon: ReactNode; label: string; active: boolean; badge?: number }) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className={`rail-link ${active ? "active" : ""}`} title={label} aria-label={label} aria-current={active ? "page" : undefined}>
|
||||||
|
{icon}
|
||||||
|
{badge > 0 && <span className="rail-badge">{badge > 999 ? "999+" : badge}</span>}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Outlook-style module switcher at the bottom of the folder pane. */
|
/** Outlook-style module switcher at the bottom of the folder pane. */
|
||||||
function ModuleLink({ href, icon, label, active }: { href: string; icon: ReactNode; label: string; active: boolean }) {
|
function ModuleLink({ href, icon, label, active }: { href: string; icon: ReactNode; label: string; active: boolean }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ export function Composer({ draft }: { draft: Draft }) {
|
|||||||
already knows the name they are half-way through typing. */}
|
already knows the name they are half-way through typing. */}
|
||||||
<button type="button" className="link-btn" onClick={() => setAddressBookOpen(true)} title={translate("Choose from address books")}>{translate("To")}</button>
|
<button type="button" className="link-btn" onClick={() => setAddressBookOpen(true)} title={translate("Choose from address books")}>{translate("To")}</button>
|
||||||
</label>
|
</label>
|
||||||
<RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder={translate("Recipients")} autoFocus={initialFocus === "to"} />
|
<RecipientInput id={`${key}-to`} value={d.to} exclude={[...d.cc, ...d.bcc]} onChange={(to) => patch({ to })} placeholder={translate("Recipients")} autoFocus={initialFocus === "to"} />
|
||||||
<span className="field-extra">
|
<span className="field-extra">
|
||||||
{/* Beside Cc and Bcc, because that is where someone looks when
|
{/* Beside Cc and Bcc, because that is where someone looks when
|
||||||
they are thinking about who the message goes to. The label
|
they are thinking about who the message goes to. The label
|
||||||
@@ -268,13 +268,13 @@ export function Composer({ draft }: { draft: Draft }) {
|
|||||||
{d.showCc && (
|
{d.showCc && (
|
||||||
<div className="composer-field">
|
<div className="composer-field">
|
||||||
<label htmlFor={`${key}-cc`}>{translate("Cc")}</label>
|
<label htmlFor={`${key}-cc`}>{translate("Cc")}</label>
|
||||||
<RecipientInput id={`${key}-cc`} value={d.cc} onChange={(cc) => patch({ cc })} />
|
<RecipientInput id={`${key}-cc`} value={d.cc} exclude={[...d.to, ...d.bcc]} onChange={(cc) => patch({ cc })} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{d.showBcc && (
|
{d.showBcc && (
|
||||||
<div className="composer-field">
|
<div className="composer-field">
|
||||||
<label htmlFor={`${key}-bcc`}>{translate("Bcc")}</label>
|
<label htmlFor={`${key}-bcc`}>{translate("Bcc")}</label>
|
||||||
<RecipientInput id={`${key}-bcc`} value={d.bcc} onChange={(bcc) => patch({ bcc })} />
|
<RecipientInput id={`${key}-bcc`} value={d.bcc} exclude={[...d.to, ...d.cc]} onChange={(bcc) => patch({ bcc })} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="composer-field">
|
<div className="composer-field">
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||||
|
import { t as translate } from "@/lib/i18n";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Resizing an image in the composer (Gitea issue #26).
|
||||||
|
*
|
||||||
|
* An inserted image went in at its own size, capped at the editor's width,
|
||||||
|
* and nothing could change it. A screenshot pasted from a large monitor
|
||||||
|
* filled the message. Click an image and this puts a frame round it, with a
|
||||||
|
* handle in the corner to drag it to any width, and three fixed sizes plus
|
||||||
|
* Original size above it.
|
||||||
|
*
|
||||||
|
* The frame is drawn over the editor rather than inside it, so none of it
|
||||||
|
* ends up in the message. The only thing that changes in the message is the
|
||||||
|
* image's own width: as a `width` attribute, which Outlook needs because it
|
||||||
|
* ignores CSS on images, and as an inline style, which everything else
|
||||||
|
* reads. `max-width:100%` stays, so a large image still shrinks to fit a
|
||||||
|
* phone on the receiving end.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Fixed widths, in CSS pixels, as offered in the report. */
|
||||||
|
export const IMAGE_SIZE_PRESETS: ReadonlyArray<{ label: string; width: number }> = [
|
||||||
|
{ label: "Small", width: 300 },
|
||||||
|
{ label: "Medium", width: 600 },
|
||||||
|
{ label: "Large", width: 900 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MIN_WIDTH = 24;
|
||||||
|
|
||||||
|
/** Set an image's width, or clear it with `null` to go back to its own size. */
|
||||||
|
export function setImageWidth(img: HTMLImageElement, width: number | null): void {
|
||||||
|
// A height would fight the new width and stretch the image.
|
||||||
|
img.removeAttribute("height");
|
||||||
|
img.style.height = "auto";
|
||||||
|
img.style.maxWidth = "100%";
|
||||||
|
if (width === null) {
|
||||||
|
img.removeAttribute("width");
|
||||||
|
img.style.width = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const w = Math.max(MIN_WIDTH, Math.round(width));
|
||||||
|
img.setAttribute("width", String(w));
|
||||||
|
img.style.width = `${w}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The width the image has been given, or null when it is at its own size. */
|
||||||
|
export function imageWidth(img: HTMLImageElement): number | null {
|
||||||
|
const w = Number.parseInt(img.getAttribute("width") ?? "", 10);
|
||||||
|
return Number.isFinite(w) && w > 0 ? w : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Box {
|
||||||
|
/** Where the editor area sits inside the composer, which is what the layer is positioned against. */
|
||||||
|
areaTop: number;
|
||||||
|
areaLeft: number;
|
||||||
|
areaWidth: number;
|
||||||
|
areaHeight: number;
|
||||||
|
/** The image, relative to the editor area's visible box. */
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The editable area. Its parent must be positioned, as `.composer-editor` is. */
|
||||||
|
area: HTMLDivElement;
|
||||||
|
img: HTMLImageElement;
|
||||||
|
/** Called once a change is finished, to save the draft. */
|
||||||
|
onChange: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageResizer({ area, img, onChange, onClose }: Props) {
|
||||||
|
const [box, setBox] = useState<Box | null>(null);
|
||||||
|
const [width, setWidth] = useState<number | null>(() => imageWidth(img));
|
||||||
|
const layerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const drag = useRef<{ x: number; width: number; max: number } | null>(null);
|
||||||
|
|
||||||
|
const measure = useCallback(() => {
|
||||||
|
const a = area.getBoundingClientRect();
|
||||||
|
const r = img.getBoundingClientRect();
|
||||||
|
setBox({
|
||||||
|
areaTop: area.offsetTop, areaLeft: area.offsetLeft, areaWidth: a.width, areaHeight: a.height,
|
||||||
|
top: r.top - a.top, left: r.left - a.left, width: r.width, height: r.height,
|
||||||
|
});
|
||||||
|
}, [area, img]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(img);
|
||||||
|
ro.observe(area);
|
||||||
|
area.addEventListener("scroll", measure);
|
||||||
|
window.addEventListener("resize", measure);
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
area.removeEventListener("scroll", measure);
|
||||||
|
window.removeEventListener("resize", measure);
|
||||||
|
};
|
||||||
|
}, [area, img, measure]);
|
||||||
|
|
||||||
|
// Anything but the image or the frame lets it go, and so does Escape.
|
||||||
|
useEffect(() => {
|
||||||
|
const onDown = (e: PointerEvent) => {
|
||||||
|
const target = e.target as Node;
|
||||||
|
if (target === img || layerRef.current?.contains(target)) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", onDown, true);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", onDown, true);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [img, onClose]);
|
||||||
|
|
||||||
|
const apply = (w: number | null) => {
|
||||||
|
setImageWidth(img, w);
|
||||||
|
setWidth(imageWidth(img));
|
||||||
|
measure();
|
||||||
|
onChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onHandleDown = (e: ReactPointerEvent<HTMLSpanElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
const style = getComputedStyle(area);
|
||||||
|
const max = area.clientWidth - Number.parseFloat(style.paddingLeft) - Number.parseFloat(style.paddingRight);
|
||||||
|
drag.current = { x: e.clientX, width: img.getBoundingClientRect().width, max };
|
||||||
|
};
|
||||||
|
const onHandleMove = (e: ReactPointerEvent<HTMLSpanElement>) => {
|
||||||
|
const d = drag.current;
|
||||||
|
if (!d) return;
|
||||||
|
const w = Math.min(d.max, Math.max(MIN_WIDTH, d.width + (e.clientX - d.x)));
|
||||||
|
setImageWidth(img, w);
|
||||||
|
measure();
|
||||||
|
};
|
||||||
|
const onHandleUp = (e: ReactPointerEvent<HTMLSpanElement>) => {
|
||||||
|
if (!drag.current) return;
|
||||||
|
drag.current = null;
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
setWidth(imageWidth(img));
|
||||||
|
onChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!box) return null;
|
||||||
|
// The size bar sits above the image, or inside its top edge when the image
|
||||||
|
// starts at the top of what is visible.
|
||||||
|
const barTop = Math.max(4, box.top - 34);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={layerRef}
|
||||||
|
className="img-resize-layer"
|
||||||
|
style={{ top: box.areaTop, left: box.areaLeft, width: box.areaWidth, height: box.areaHeight }}
|
||||||
|
>
|
||||||
|
<div className="img-resize-frame" style={{ top: box.top, left: box.left, width: box.width, height: box.height }}>
|
||||||
|
<span
|
||||||
|
className="img-resize-handle"
|
||||||
|
role="slider"
|
||||||
|
aria-label={translate("Drag to resize")}
|
||||||
|
aria-valuenow={Math.round(box.width)}
|
||||||
|
aria-valuemin={MIN_WIDTH}
|
||||||
|
title={translate("Drag to resize")}
|
||||||
|
onPointerDown={onHandleDown}
|
||||||
|
onPointerMove={onHandleMove}
|
||||||
|
onPointerUp={onHandleUp}
|
||||||
|
onPointerCancel={onHandleUp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="img-resize-bar" role="toolbar" aria-label={translate("Image size")} style={{ top: barTop, left: Math.max(4, box.left) }}>
|
||||||
|
{IMAGE_SIZE_PRESETS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.width}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
aria-pressed={width === p.width}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => apply(p.width)}
|
||||||
|
>
|
||||||
|
{translate(p.label)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button type="button" className="btn btn-sm" aria-pressed={width === null} onMouseDown={(e) => e.preventDefault()} onClick={() => apply(null)}>
|
||||||
|
{translate("Original size")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,9 +12,11 @@ interface Props {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
/** Addresses already in the message's other recipient fields, not to be suggested again. */
|
||||||
|
exclude?: EmailAddress[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RecipientInput({ value, onChange, placeholder, autoFocus, id }: Props) {
|
export function RecipientInput({ value, onChange, placeholder, autoFocus, id, exclude }: Props) {
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [sugg, setSugg] = useState<Suggestion[]>([]);
|
const [sugg, setSugg] = useState<Suggestion[]>([]);
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
@@ -34,7 +36,7 @@ export function RecipientInput({ value, onChange, placeholder, autoFocus, id }:
|
|||||||
const t = window.setTimeout(() => {
|
const t = window.setTimeout(() => {
|
||||||
void suggest(q).then((list) => {
|
void suggest(q).then((list) => {
|
||||||
if (id !== reqId.current) return;
|
if (id !== reqId.current) return;
|
||||||
const existing = new Set(value.map((v) => v.email.toLowerCase()));
|
const existing = new Set([...value, ...(exclude ?? [])].map((v) => v.email.toLowerCase()));
|
||||||
const filtered = list.filter((s) => !existing.has(s.email.toLowerCase()));
|
const filtered = list.filter((s) => !existing.has(s.email.toLowerCase()));
|
||||||
setSugg(filtered);
|
setSugg(filtered);
|
||||||
setActive(0);
|
setActive(0);
|
||||||
@@ -42,7 +44,7 @@ export function RecipientInput({ value, onChange, placeholder, autoFocus, id }:
|
|||||||
});
|
});
|
||||||
}, 120);
|
}, 120);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [text, suggest, value]);
|
}, [text, suggest, value, exclude]);
|
||||||
|
|
||||||
const commit = (raw?: string) => {
|
const commit = (raw?: string) => {
|
||||||
const s = (raw ?? text).trim().replace(/[,;]+$/, "");
|
const s = (raw ?? text).trim().replace(/[,;]+$/, "");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { AlignCenter, AlignLeft, AlignRight, Bold, Code, Eraser, Image as ImageI
|
|||||||
import { sanitizeEditorHtml } from "@/lib/text/html";
|
import { sanitizeEditorHtml } from "@/lib/text/html";
|
||||||
import { Popover, useMenu } from "@/ui/popover";
|
import { Popover, useMenu } from "@/ui/popover";
|
||||||
import { t as translate } from "@/lib/i18n";
|
import { t as translate } from "@/lib/i18n";
|
||||||
|
import { ImageResizer } from "./ImageResizer";
|
||||||
|
|
||||||
export interface RichEditorHandle {
|
export interface RichEditorHandle {
|
||||||
focus(): void;
|
focus(): void;
|
||||||
@@ -24,6 +25,15 @@ interface Props {
|
|||||||
imageUpload?: (file: File) => Promise<string>;
|
imageUpload?: (file: File) => Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pasted or dropped images larger than this go in as attachments, not inline. */
|
||||||
|
export const INLINE_IMAGE_MAX = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** A single http(s) or mailto address, nothing else: what may become a link over a selection. */
|
||||||
|
export function isLinkToPaste(text: string): boolean {
|
||||||
|
if (!text || /\s/.test(text)) return false;
|
||||||
|
return /^(https?:\/\/[^\s]+|mailto:[^\s@]+@[^\s@]+)$/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
const EMOJI = "😀 😃 😄 😁 😆 😅 😂 🤣 🙂 😉 😊 😇 🥰 😍 😘 😋 😜 🤪 🤗 🤔 🤫 🤐 😐 😑 😶 😏 😒 🙄 😬 😌 😔 😪 😴 😷 🤒 🤕 🤢 🤮 🥵 🥶 🥴 😵 🤯 🤠 🥳 😎 🤓 🧐 😕 😟 🙁 😮 😯 😲 😳 🥺 😦 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 😓 😩 😫 🥱 😤 😡 😠 🤬 👍 👎 👌 ✌️ 🤞 🤟 🤘 🤙 👈 👉 👆 👇 ☝️ 👋 🤚 🖐️ ✋ 🖖 👏 🙌 👐 🤲 🤝 🙏 💪 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ❣️ 💕 💯 💥 🔥 ✨ 🎉 🎊 🎈 🎁 🏆 ⭐ 🌟 ☀️ 🌙 ⚡ ☕ 🍕 🍺 🚀 ✈️ 🏠 💼 📅 📎 📌 ✅ ❌ ⚠️ ❓ ❗ 💡 🔔 📧 🙈 🙉 🙊 🐱 🐶 🦊 🐼".split(" ");
|
const EMOJI = "😀 😃 😄 😁 😆 😅 😂 🤣 🙂 😉 😊 😇 🥰 😍 😘 😋 😜 🤪 🤗 🤔 🤫 🤐 😐 😑 😶 😏 😒 🙄 😬 😌 😔 😪 😴 😷 🤒 🤕 🤢 🤮 🥵 🥶 🥴 😵 🤯 🤠 🥳 😎 🤓 🧐 😕 😟 🙁 😮 😯 😲 😳 🥺 😦 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 😓 😩 😫 🥱 😤 😡 😠 🤬 👍 👎 👌 ✌️ 🤞 🤟 🤘 🤙 👈 👉 👆 👇 ☝️ 👋 🤚 🖐️ ✋ 🖖 👏 🙌 👐 🤲 🤝 🙏 💪 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ❣️ 💕 💯 💥 🔥 ✨ 🎉 🎊 🎈 🎁 🏆 ⭐ 🌟 ☀️ 🌙 ⚡ ☕ 🍕 🍺 🚀 ✈️ 🏠 💼 📅 📎 📌 ✅ ❌ ⚠️ ❓ ❗ 💡 🔔 📧 🙈 🙉 🙊 🐱 🐶 🦊 🐼".split(" ");
|
||||||
const COLORS = ["#000000", "#434343", "#666666", "#999999", "#b7b7b7", "#cccccc", "#d9d9d9", "#ffffff", "#980000", "#ff0000", "#ff9900", "#ffff00", "#00ff00", "#00ffff", "#4a86e8", "#0000ff", "#9900ff", "#ff00ff", "#e6b8af", "#f4cccc", "#fce5cd", "#fff2cc", "#d9ead3", "#d0e0e3", "#c9daf8", "#cfe2f3", "#d9d2e9", "#ead1dc", "#cc4125", "#e06666", "#f6b26b", "#ffd966", "#93c47d", "#76a5af", "#6d9eeb", "#6fa8dc", "#8e7cc3", "#c27ba0", "#a61c00", "#cc0000", "#e69138", "#f1c232", "#6aa84f", "#45818e", "#3c78d8", "#3d85c6", "#674ea7", "#a64d79"];
|
const COLORS = ["#000000", "#434343", "#666666", "#999999", "#b7b7b7", "#cccccc", "#d9d9d9", "#ffffff", "#980000", "#ff0000", "#ff9900", "#ffff00", "#00ff00", "#00ffff", "#4a86e8", "#0000ff", "#9900ff", "#ff00ff", "#e6b8af", "#f4cccc", "#fce5cd", "#fff2cc", "#d9ead3", "#d0e0e3", "#c9daf8", "#cfe2f3", "#d9d2e9", "#ead1dc", "#cc4125", "#e06666", "#f6b26b", "#ffd966", "#93c47d", "#76a5af", "#6d9eeb", "#6fa8dc", "#8e7cc3", "#c27ba0", "#a61c00", "#cc0000", "#e69138", "#f1c232", "#6aa84f", "#45818e", "#3c78d8", "#3d85c6", "#674ea7", "#a64d79"];
|
||||||
|
|
||||||
@@ -37,6 +47,8 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
|||||||
const linkMenu = useMenu();
|
const linkMenu = useMenu();
|
||||||
const [linkUrl, setLinkUrl] = useState("");
|
const [linkUrl, setLinkUrl] = useState("");
|
||||||
const savedRange = useRef<Range | null>(null);
|
const savedRange = useRef<Range | null>(null);
|
||||||
|
/** The image whose size is being changed, if one has been clicked. */
|
||||||
|
const [selectedImg, setSelectedImg] = useState<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
// Sync external html → DOM (only when it differs from what we emitted)
|
// Sync external html → DOM (only when it differs from what we emitted)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -45,6 +57,7 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
|||||||
if (html !== lastEmitted.current) {
|
if (html !== lastEmitted.current) {
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
lastEmitted.current = html;
|
lastEmitted.current = html;
|
||||||
|
setSelectedImg(null);
|
||||||
setEmpty(!el.textContent?.trim() && !el.querySelector("img"));
|
setEmpty(!el.textContent?.trim() && !el.querySelector("img"));
|
||||||
}
|
}
|
||||||
}, [html]);
|
}, [html]);
|
||||||
@@ -72,6 +85,8 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
const v = el.innerHTML;
|
const v = el.innerHTML;
|
||||||
lastEmitted.current = v;
|
lastEmitted.current = v;
|
||||||
|
// Deleted, or typed over: there is nothing left to resize.
|
||||||
|
setSelectedImg((img) => (img && el.contains(img) ? img : null));
|
||||||
setEmpty(!el.textContent?.trim() && !el.querySelector("img"));
|
setEmpty(!el.textContent?.trim() && !el.querySelector("img"));
|
||||||
onChange(v);
|
onChange(v);
|
||||||
}, [onChange]);
|
}, [onChange]);
|
||||||
@@ -127,10 +142,25 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
|||||||
const f = imgItem.getAsFile();
|
const f = imgItem.getAsFile();
|
||||||
if (f) {
|
if (f) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
insertImageFile(f);
|
// A large image is a file to send, not a picture in the text: it goes
|
||||||
|
// in as an attachment, where an inline copy would swell every reply.
|
||||||
|
if (f.size > INLINE_IMAGE_MAX && onFiles) onFiles([f]);
|
||||||
|
else insertImageFile(f);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
* A link pasted over selected words makes those words the link, the way
|
||||||
|
* most editors do, instead of replacing them with the address.
|
||||||
|
*/
|
||||||
|
const pastedText = e.clipboardData.getData("text/plain").trim();
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (isLinkToPaste(pastedText) && sel && !sel.isCollapsed && elRef.current?.contains(sel.anchorNode)) {
|
||||||
|
e.preventDefault();
|
||||||
|
document.execCommand("createLink", false, pastedText);
|
||||||
|
emit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const htmlData = e.clipboardData.getData("text/html");
|
const htmlData = e.clipboardData.getData("text/html");
|
||||||
if (htmlData) {
|
if (htmlData) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -169,8 +199,9 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
|||||||
const files = Array.from(e.dataTransfer.files);
|
const files = Array.from(e.dataTransfer.files);
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const images = files.filter((f) => f.type.startsWith("image/"));
|
const inline = (f: File) => f.type.startsWith("image/") && !(f.size > INLINE_IMAGE_MAX && onFiles);
|
||||||
const others = files.filter((f) => !f.type.startsWith("image/"));
|
const images = files.filter(inline);
|
||||||
|
const others = files.filter((f) => !inline(f));
|
||||||
images.forEach(insertImageFile);
|
images.forEach(insertImageFile);
|
||||||
if (others.length) onFiles?.(others);
|
if (others.length) onFiles?.(others);
|
||||||
};
|
};
|
||||||
@@ -205,6 +236,14 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
|||||||
onMouseUp={saveRange}
|
onMouseUp={saveRange}
|
||||||
onPaste={onPaste}
|
onPaste={onPaste}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
|
onClick={(e) => setSelectedImg(e.target instanceof HTMLImageElement ? e.target : null)}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
// A right-click on an image offers its sizes, which is where the
|
||||||
|
// report went looking for them, instead of the browser's menu.
|
||||||
|
if (!(e.target instanceof HTMLImageElement)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedImg(e.target);
|
||||||
|
}}
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||||
@@ -221,6 +260,9 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
|||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
aria-label={translate("Message body")}
|
aria-label={translate("Message body")}
|
||||||
/>
|
/>
|
||||||
|
{selectedImg && elRef.current && (
|
||||||
|
<ImageResizer area={elRef.current} img={selectedImg} onChange={emit} onClose={() => setSelectedImg(null)} />
|
||||||
|
)}
|
||||||
{showToolbar && (
|
{showToolbar && (
|
||||||
<div className="editor-toolbar" role="toolbar" aria-label={translate("Formatting")}>
|
<div className="editor-toolbar" role="toolbar" aria-label={translate("Formatting")}>
|
||||||
<button type="button" className="icon-btn" title={translate("Undo (Ctrl+Z)")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("undo")}><Undo size={16} /></button>
|
<button type="button" className="icon-btn" title={translate("Undo (Ctrl+Z)")} onMouseDown={(e) => e.preventDefault()} onClick={() => exec("undo")}><Undo size={16} /></button>
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { INLINE_IMAGE_MAX, RichEditor, isLinkToPaste } from "../RichEditor";
|
||||||
|
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Two things pasting into a message now does: a link pasted over selected
|
||||||
|
* words turns those words into the link, and an image too large to belong
|
||||||
|
* in the text goes in as an attachment. jsdom does no editing, so these check
|
||||||
|
* the command and the callback the editor reaches for.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("what counts as a link to paste", () => {
|
||||||
|
it("takes one web or mailto address", () => {
|
||||||
|
expect(isLinkToPaste("https://example.com/a?b=1")).toBe(true);
|
||||||
|
expect(isLinkToPaste("http://example.com")).toBe(true);
|
||||||
|
expect(isLinkToPaste("mailto:[email protected]")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves ordinary text and anything with spaces alone", () => {
|
||||||
|
expect(isLinkToPaste("see https://example.com")).toBe(false);
|
||||||
|
expect(isLinkToPaste("example.com")).toBe(false);
|
||||||
|
expect(isLinkToPaste("javascript:alert(1)")).toBe(false);
|
||||||
|
expect(isLinkToPaste("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pasting into the editor", () => {
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
let onFiles: ReturnType<typeof vi.fn<(files: File[]) => void>>;
|
||||||
|
let exec: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
host = document.createElement("div");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
onFiles = vi.fn<(files: File[]) => void>();
|
||||||
|
exec = vi.fn(() => true);
|
||||||
|
(document as unknown as { execCommand: unknown }).execCommand = exec;
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<RichEditor html="<p>read the docs here</p>" onChange={() => {}} onFiles={onFiles} showToolbar={false} />);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
host.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
const editor = () => host.querySelector<HTMLElement>('[contenteditable="true"]')!;
|
||||||
|
|
||||||
|
const paste = async (data: { text?: string; html?: string; files?: File[] }) => {
|
||||||
|
const items = (data.files ?? []).map((f) => ({ type: f.type, kind: "file", getAsFile: () => f }));
|
||||||
|
const ev = new Event("paste", { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(ev, "clipboardData", {
|
||||||
|
value: { items, getData: (t: string) => (t === "text/plain" ? data.text ?? "" : t === "text/html" ? data.html ?? "" : "") },
|
||||||
|
});
|
||||||
|
await act(async () => editor().dispatchEvent(ev));
|
||||||
|
return ev;
|
||||||
|
};
|
||||||
|
|
||||||
|
const select = (word: string) => {
|
||||||
|
const text = editor().querySelector("p")!.firstChild!;
|
||||||
|
const i = text.textContent!.indexOf(word);
|
||||||
|
const r = document.createRange();
|
||||||
|
r.setStart(text, i);
|
||||||
|
r.setEnd(text, i + word.length);
|
||||||
|
const sel = window.getSelection()!;
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(r);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("turns selected words into the pasted link", async () => {
|
||||||
|
select("docs");
|
||||||
|
const ev = await paste({ text: "https://example.com/docs" });
|
||||||
|
expect(ev.defaultPrevented).toBe(true);
|
||||||
|
expect(exec).toHaveBeenCalledWith("createLink", false, "https://example.com/docs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pastes a link as text when nothing is selected", async () => {
|
||||||
|
window.getSelection()!.removeAllRanges();
|
||||||
|
await paste({ text: "https://example.com/docs" });
|
||||||
|
expect(exec).not.toHaveBeenCalledWith("createLink", expect.anything(), expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches a pasted image too large to sit in the text", async () => {
|
||||||
|
const big = new File([new Uint8Array(8)], "huge.png", { type: "image/png" });
|
||||||
|
Object.defineProperty(big, "size", { value: INLINE_IMAGE_MAX + 1 });
|
||||||
|
await paste({ files: [big] });
|
||||||
|
expect(onFiles).toHaveBeenCalledWith([big]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a normal-sized pasted image in the text", async () => {
|
||||||
|
const small = new File([new Uint8Array(8)], "shot.png", { type: "image/png" });
|
||||||
|
await paste({ files: [small] });
|
||||||
|
expect(onFiles).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { RichEditor } from "../RichEditor";
|
||||||
|
import { imageWidth, setImageWidth } from "../ImageResizer";
|
||||||
|
import { sanitizeEditorHtml } from "@/lib/text/html";
|
||||||
|
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* An image in the composer could not be resized at all (Gitea issue #26).
|
||||||
|
* jsdom does no layout, so dragging the handle is checked in a browser; these
|
||||||
|
* pin what a size does to the message, and that the frame never ends up in it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("setting an image's width", () => {
|
||||||
|
it("writes both the attribute Outlook reads and the style everything else reads", () => {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.setAttribute("height", "480");
|
||||||
|
setImageWidth(img, 300);
|
||||||
|
expect(img.getAttribute("width")).toBe("300");
|
||||||
|
expect(img.style.width).toBe("300px");
|
||||||
|
// A leftover height would stretch it out of shape.
|
||||||
|
expect(img.hasAttribute("height")).toBe(false);
|
||||||
|
expect(img.style.height).toBe("auto");
|
||||||
|
// Still shrinks to fit a narrow screen on the receiving end.
|
||||||
|
expect(img.style.maxWidth).toBe("100%");
|
||||||
|
expect(imageWidth(img)).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("goes back to the image's own size", () => {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
setImageWidth(img, 600);
|
||||||
|
setImageWidth(img, null);
|
||||||
|
expect(img.hasAttribute("width")).toBe(false);
|
||||||
|
expect(img.style.width).toBe("");
|
||||||
|
expect(imageWidth(img)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives the sanitizer a draft goes through", () => {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = "https://example.com/a.png";
|
||||||
|
setImageWidth(img, 450);
|
||||||
|
const out = sanitizeEditorHtml(img.outerHTML);
|
||||||
|
expect(out).toContain('width="450"');
|
||||||
|
expect(out).toContain("width: 450px");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resizing an image in the editor", () => {
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
let onChange: ReturnType<typeof vi.fn<(html: string) => void>>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
host = document.createElement("div");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
onChange = vi.fn<(html: string) => void>();
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<RichEditor html='<p>Hi</p><img src="data:image/png;base64,AA==" alt="shot" style="max-width:100%">' onChange={onChange} showToolbar={false} />);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
host.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
const editor = () => host.querySelector<HTMLElement>('[contenteditable="true"]')!;
|
||||||
|
const img = () => editor().querySelector("img")!;
|
||||||
|
const button = (label: string) => [...host.querySelectorAll<HTMLButtonElement>(".img-resize-bar button")].find((b) => b.textContent === label);
|
||||||
|
|
||||||
|
it("offers sizes when the image is clicked, and applies one", async () => {
|
||||||
|
expect(host.querySelector(".img-resize-bar")).toBeNull();
|
||||||
|
await act(async () => img().click());
|
||||||
|
expect(button("Medium")).toBeTruthy();
|
||||||
|
await act(async () => button("Medium")!.click());
|
||||||
|
expect(imageWidth(img())).toBe(600);
|
||||||
|
expect(button("Medium")!.getAttribute("aria-pressed")).toBe("true");
|
||||||
|
// The draft is saved with the new width in it.
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(expect.stringContaining('width="600"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers them on a right-click too, instead of the browser's menu", async () => {
|
||||||
|
const ev = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
|
||||||
|
await act(async () => img().dispatchEvent(ev));
|
||||||
|
expect(ev.defaultPrevented).toBe(true);
|
||||||
|
expect(button("Original size")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never puts the frame or its buttons into the message", async () => {
|
||||||
|
await act(async () => img().click());
|
||||||
|
await act(async () => button("Small")!.click());
|
||||||
|
const sent = onChange.mock.lastCall![0] as string;
|
||||||
|
expect(sent).not.toContain("img-resize");
|
||||||
|
expect(sent).not.toContain("Original size");
|
||||||
|
expect(editor().querySelector(".img-resize-layer")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets go on a click elsewhere and on Escape", async () => {
|
||||||
|
await act(async () => img().click());
|
||||||
|
await act(async () => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
|
||||||
|
expect(host.querySelector(".img-resize-bar")).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => img().click());
|
||||||
|
await act(async () => editor().querySelector("p")!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })));
|
||||||
|
expect(host.querySelector(".img-resize-bar")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { RecipientInput } from "../RecipientInput";
|
||||||
|
import { useContacts } from "@/store/contacts";
|
||||||
|
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Someone already in Cc was still offered when typing in To, so it was easy
|
||||||
|
* to address the same person twice from two fields.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("suggestions across To, Cc and Bcc", () => {
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
host = document.createElement("div");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
useContacts.setState({
|
||||||
|
suggest: (async () => [
|
||||||
|
{ name: "Ann Lee", email: "[email protected]", source: "contact" },
|
||||||
|
{ name: "Ann Taylor", email: "[email protected]", source: "contact" },
|
||||||
|
]) as never,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
host.remove();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves out an address already in another recipient field", async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<RecipientInput value={[]} exclude={[{ name: null, email: "[email protected]" }]} onChange={() => {}} />);
|
||||||
|
});
|
||||||
|
const input = host.querySelector("input")!;
|
||||||
|
await act(async () => {
|
||||||
|
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||||
|
set.call(input, "ann");
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
const offered = host.textContent ?? "";
|
||||||
|
expect(offered).toContain("[email protected]");
|
||||||
|
expect(offered).not.toContain("[email protected]");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -438,7 +438,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
|||||||
>
|
>
|
||||||
{twisty ? open ? <ChevronDown size={14} /> : <ChevronRight size={14} /> : null}
|
{twisty ? open ? <ChevronDown size={14} /> : <ChevronRight size={14} /> : null}
|
||||||
</span>
|
</span>
|
||||||
<span className="folder-icon" style={tint ? ({ "--folder-color": tint } as React.CSSProperties) : undefined}>{icon}</span>
|
<span className="folder-icon" data-role={m.role ?? (scheduled ? "scheduled" : undefined)} style={tint ? ({ "--folder-color": tint } as React.CSSProperties) : undefined}>{icon}</span>
|
||||||
<span className="nav-label">{label}</span>
|
<span className="nav-label">{label}</span>
|
||||||
{count > 0 && <span className="nav-count" title={hiddenUnread ? t("{here} here, {inSubfolders} in subfolders", { here: own, inSubfolders: hiddenUnread }) : undefined}>{count > 9999 ? "9999+" : count}</span>}
|
{count > 0 && <span className="nav-count" title={hiddenUnread ? t("{here} here, {inSubfolders} in subfolders", { here: own, inSubfolders: hiddenUnread }) : undefined}>{count > 9999 ? "9999+" : count}</span>}
|
||||||
{count > 0 && <span className="nav-dot" />}
|
{count > 0 && <span className="nav-dot" />}
|
||||||
|
|||||||
@@ -795,6 +795,9 @@ function RowView({ email: e, threadEmails, top, height, selected, focused, open,
|
|||||||
<span className="truncate">{who}</span>
|
<span className="truncate">{who}</span>
|
||||||
{count > 1 && <span className="thread-count"> {count}</span>}
|
{count > 1 && <span className="thread-count"> {count}</span>}
|
||||||
</span>
|
</span>
|
||||||
|
{/* On the sender's line, which has room to spare: a third line of
|
||||||
|
their own did not fit the row at Cozy or Compact (#35). */}
|
||||||
|
{rowLabels.length > 0 && <div className="msg-labels">{rowLabels.map((l) => <span key={l.keyword} className="tag" style={{ background: l.color }}>{l.name}</span>)}</div>}
|
||||||
<span className="msg-meta">
|
<span className="msg-meta">
|
||||||
{hasAtt && <Paperclip size={14} className="msg-attach" />}
|
{hasAtt && <Paperclip size={14} className="msg-attach" />}
|
||||||
<span className="msg-date">{formatListDate(latest.receivedAt)}</span>
|
<span className="msg-date">{formatListDate(latest.receivedAt)}</span>
|
||||||
@@ -808,7 +811,6 @@ function RowView({ email: e, threadEmails, top, height, selected, focused, open,
|
|||||||
<Star size={16} fill={starred ? "currentColor" : "none"} />
|
<Star size={16} fill={starred ? "currentColor" : "none"} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{rowLabels.length > 0 && <div className="msg-labels">{rowLabels.map((l) => <span key={l.keyword} className="tag" style={{ background: l.color }}>{l.name}</span>)}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -120,6 +120,22 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
|
|||||||
for (const m of messages) if (!m.keywords.$seen) unreadAtOpen.current.ids.add(m.id);
|
for (const m of messages) if (!m.keywords.$seen) unreadAtOpen.current.ids.add(m.id);
|
||||||
const wasUnread = unreadAtOpen.current.ids;
|
const wasUnread = unreadAtOpen.current.ids;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Which messages have been read while this conversation was open.
|
||||||
|
*
|
||||||
|
* Mark as unread on an open message used to undo itself: clearing `$seen`
|
||||||
|
* re-ran the timer below, which found an expanded unread message and marked
|
||||||
|
* it read again a moment later (Gitea issue #25). A message that has been
|
||||||
|
* read here and is unread again was made so on purpose -- by this reader or
|
||||||
|
* on another device -- so the timer leaves it alone until the conversation
|
||||||
|
* is opened again. Mail that arrives unread was never read here, and is
|
||||||
|
* still marked read as before. Accumulated during render, like the set
|
||||||
|
* above.
|
||||||
|
*/
|
||||||
|
const readWhileOpen = useRef<{ key: Id | null; ids: Set<Id> }>({ key: null, ids: new Set() });
|
||||||
|
if (readWhileOpen.current.key !== threadKey) readWhileOpen.current = { key: threadKey, ids: new Set() };
|
||||||
|
for (const m of messages) if (m.keywords.$seen) readWhileOpen.current.ids.add(m.id);
|
||||||
|
|
||||||
// Default expansion: unread when opened + last message expanded, others collapsed
|
// Default expansion: unread when opened + last message expanded, others collapsed
|
||||||
const lastId = messages[messages.length - 1]?.id;
|
const lastId = messages[messages.length - 1]?.id;
|
||||||
const isExpanded = useCallback(
|
const isExpanded = useCallback(
|
||||||
@@ -137,7 +153,7 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
|
|||||||
const readOnly = useViewingDelegation()?.access === "read";
|
const readOnly = useViewingDelegation()?.access === "read";
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!messages.length || readOnly) return;
|
if (!messages.length || readOnly) return;
|
||||||
const unread = messages.filter((e) => !e.keywords.$seen && isExpanded(e)).map((e) => e.id);
|
const unread = messages.filter((e) => !e.keywords.$seen && isExpanded(e) && !readWhileOpen.current.ids.has(e.id)).map((e) => e.id);
|
||||||
if (!unread.length || settings.markReadDelay < 0) return;
|
if (!unread.length || settings.markReadDelay < 0) return;
|
||||||
if (markTimer.current) window.clearTimeout(markTimer.current);
|
if (markTimer.current) window.clearTimeout(markTimer.current);
|
||||||
markTimer.current = window.setTimeout(() => void useMail.getState().markRead(unread, true), settings.markReadDelay * 1000);
|
markTimer.current = window.setTimeout(() => void useMail.getState().markRead(unread, true), settings.markReadDelay * 1000);
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { MailboxTree } from "../MailboxTree";
|
||||||
|
import { useMail } from "@/store/mail";
|
||||||
|
import { useSettings } from "@/store/settings";
|
||||||
|
import type { Mailbox, MailboxRole } from "@/jmap/types";
|
||||||
|
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
window.matchMedia = ((q: string) => ({ matches: false, media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Role folders get their own icon tint, read from the icon's data-role; a
|
||||||
|
* plain folder has none, and a color the reader chose still applies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const rights = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true };
|
||||||
|
const box = (id: string, name: string, role: MailboxRole, unread = 0): Mailbox => ({
|
||||||
|
id, name, parentId: null, role, sortOrder: 0, totalEmails: unread, unreadEmails: unread, totalThreads: unread, unreadThreads: unread, myRights: rights, isSubscribed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("folder icons by role", () => {
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
beforeEach(() => {
|
||||||
|
window.history.replaceState({}, "", "/mail/inbox");
|
||||||
|
useMail.setState({
|
||||||
|
mailboxes: { inbox: box("inbox", "Inbox", "inbox", 3), junk: box("junk", "Junk", "junk", 1), work: box("work", "Work", null, 2) },
|
||||||
|
mailboxesLoaded: true,
|
||||||
|
});
|
||||||
|
useSettings.setState((s) => ({ settings: { ...s.settings, showHiddenFolders: false, labelsSidebar: false, folderColors: { junk: "#123456" } } }));
|
||||||
|
host = document.createElement("div");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
act(() => root.render(<MailboxTree />));
|
||||||
|
});
|
||||||
|
afterEach(() => { act(() => root.unmount()); host.remove(); });
|
||||||
|
|
||||||
|
const icons = () => Array.from(document.querySelectorAll<HTMLElement>(".nav-item.folder-row .folder-icon"));
|
||||||
|
const byRole = (role: string) => icons().find((i) => i.dataset.role === role)!;
|
||||||
|
|
||||||
|
it("marks role folders with their role and leaves a plain folder unmarked", () => {
|
||||||
|
expect(icons().map((i) => i.dataset.role ?? null).sort()).toEqual(["inbox", "junk", null].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the reader's own color on a role folder", () => {
|
||||||
|
expect(byRole("junk").style.getPropertyValue("--folder-color")).toBe("#123456");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows unread counts as pills", () => {
|
||||||
|
const pill = Array.from(document.querySelectorAll(".nav-item.folder-row.unread .nav-count")).map((p) => p.textContent);
|
||||||
|
expect(pill).toEqual(expect.arrayContaining(["3", "1", "2"]));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ThreadView } from "../ThreadView";
|
||||||
|
import { useMail } from "@/store/mail";
|
||||||
|
import type { ListActions } from "../MessageList";
|
||||||
|
import type { Email, Id } from "@/jmap/types";
|
||||||
|
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
Element.prototype.scrollIntoView = () => {};
|
||||||
|
globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver;
|
||||||
|
window.matchMedia = ((q: string) => ({ matches: false, media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mark as unread on an open message undid itself: the unread count showed 1
|
||||||
|
* for a moment, then the message was read again (Gitea issue #25). Clearing
|
||||||
|
* `$seen` re-ran the mark-read timer, which found an open unread message and
|
||||||
|
* did its job.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const msg = (id: Id, seen: boolean, receivedAt: string): Email =>
|
||||||
|
({
|
||||||
|
id, threadId: "t1", subject: "Hello", mailboxIds: { inbox: true }, keywords: seen ? { $seen: true } : {},
|
||||||
|
from: [{ name: "Ann", email: "[email protected]" }], to: [{ name: "Me", email: "[email protected]" }],
|
||||||
|
receivedAt, size: 10, blobId: "b1", preview: "hi",
|
||||||
|
htmlBody: [], textBody: [{ partId: "1", type: "text/plain" }],
|
||||||
|
bodyValues: { "1": { value: "hi", isEncodingProblem: false, isTruncated: false } },
|
||||||
|
attachments: [],
|
||||||
|
}) as unknown as Email;
|
||||||
|
|
||||||
|
describe("marking an open message unread", () => {
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
let markRead: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const setEmails = async (emails: Email[]) => {
|
||||||
|
await act(async () => {
|
||||||
|
useMail.setState({
|
||||||
|
threads: { t1: { id: "t1", emailIds: emails.map((e) => e.id) } } as never,
|
||||||
|
emails: Object.fromEntries(emails.map((e) => [e.id, e])) as never,
|
||||||
|
fullIds: Object.fromEntries(emails.map((e) => [e.id, true])) as never,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const show = async () => {
|
||||||
|
const actions = {} as unknown as ListActions;
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<ThreadView
|
||||||
|
threadId="t1" mailboxId="inbox" messageId={null} actions={actions}
|
||||||
|
onBack={() => undefined} onNavigate={() => undefined} hasPrev={false} hasNext={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const runTimers = async () => {
|
||||||
|
await act(async () => {
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
markRead = vi.fn(async () => undefined);
|
||||||
|
useMail.setState({
|
||||||
|
accountId: "a1",
|
||||||
|
loadingThreads: {} as never,
|
||||||
|
mailboxes: { inbox: { id: "inbox", name: "Inbox", role: "inbox" } } as never,
|
||||||
|
loadThread: (async () => undefined) as never,
|
||||||
|
setOpenThread: (() => undefined) as never,
|
||||||
|
markRead: markRead as never,
|
||||||
|
roleId: (() => null) as never,
|
||||||
|
});
|
||||||
|
host = document.createElement("div");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
host.remove();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves it unread instead of marking it read again", async () => {
|
||||||
|
await setEmails([msg("m1", true, "2026-10-01T10:00:00Z")]);
|
||||||
|
await show();
|
||||||
|
await runTimers();
|
||||||
|
expect(markRead).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// What the button does: the server clears $seen and the store follows.
|
||||||
|
await setEmails([msg("m1", false, "2026-10-01T10:00:00Z")]);
|
||||||
|
await runTimers();
|
||||||
|
expect(markRead).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still marks a message read that was unread when the thread opened", async () => {
|
||||||
|
await setEmails([msg("m1", false, "2026-10-01T10:00:00Z")]);
|
||||||
|
await show();
|
||||||
|
await runTimers();
|
||||||
|
expect(markRead).toHaveBeenCalledWith(["m1"], true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-mark it after the timer has already read it once", async () => {
|
||||||
|
// Opened unread, read by the timer, then marked unread by the reader.
|
||||||
|
await setEmails([msg("m1", false, "2026-10-01T10:00:00Z")]);
|
||||||
|
await show();
|
||||||
|
await runTimers();
|
||||||
|
expect(markRead).toHaveBeenCalledTimes(1);
|
||||||
|
await setEmails([msg("m1", true, "2026-10-01T10:00:00Z")]);
|
||||||
|
await setEmails([msg("m1", false, "2026-10-01T10:00:00Z")]);
|
||||||
|
await runTimers();
|
||||||
|
expect(markRead).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still marks a reply read that arrives while the thread is open", async () => {
|
||||||
|
await setEmails([msg("m1", true, "2026-10-01T10:00:00Z")]);
|
||||||
|
await show();
|
||||||
|
await setEmails([msg("m1", true, "2026-10-01T10:00:00Z"), msg("m2", false, "2026-10-01T11:00:00Z")]);
|
||||||
|
await runTimers();
|
||||||
|
expect(markRead).toHaveBeenCalledWith(["m2"], true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -113,6 +113,14 @@ export function CalendarSettings() {
|
|||||||
hint={t("A calendar of its own, derived from the birthdays already on your contact cards. Nothing is written anywhere — the dates stay on the cards, and an event disappears when the contact does or the birthday is cleared. It can be hidden from the calendar\u2019s own sidebar without turning it off here.")}
|
hint={t("A calendar of its own, derived from the birthdays already on your contact cards. Nothing is written anywhere — the dates stay on the cards, and an event disappears when the contact does or the birthday is cleared. It can be hidden from the calendar\u2019s own sidebar without turning it off here.")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<h2>{t("Contacts")}</h2>
|
||||||
|
<Switch
|
||||||
|
checked={s.collectRecipients}
|
||||||
|
onChange={(v) => update({ collectRecipients: v })}
|
||||||
|
label={t("Save people you write to as contacts")}
|
||||||
|
hint={t("Everyone you send to who isn\u2019t a contact yet is added to the Collected address book, so they\u2019re suggested on every device. Your own addresses are never added.")}
|
||||||
|
/>
|
||||||
|
|
||||||
<h2>{t("Working hours")}</h2>
|
<h2>{t("Working hours")}</h2>
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
|
|||||||
Reference in new issue
Block a user