Compare commits

..
Author SHA1 Message Date
jcoffey-dev 3434a5ed39 Merge pull request 'Fixes and Turkish from public ihasmail (#29–#36)' (#41) from sync/ihasmail-2026-10-05 into main
github/ci (branch) GitHub Actions
ci / node (push) Skipped
ci / version (push) Skipped
github/ci (tag inbuxa-v2026.10.5-g3434a5e) GitHub Actions
ci / github (push) Successful in 3m44s
ci / docker-build (push) Skipped
ci / publish (push) Skipped
announce / announce (release) Successful in 25s
ci / announce (push) Successful in 28s
2026-10-05 17:43:08 +00:00
jcoffey-dev 64b5db01f3 Keep a narrow list's labels inside the row, on the sender's line (ihasmail #36)
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m25s
ci / announce (pull_request) Skipped
On a phone or a narrow list, rows are two lines, and labels went on a third
line of their own. A row is a fixed height at each density, and that third
line fit it only at Comfortable: at Cozy, the default, the labels were cut
off at the bottom, and at Compact they sat outside the row.

They now sit on the sender's line, after the name, which has room to spare,
so the row's height is unchanged at every density. When the line runs short
the name gives way first, then each label truncates; the date stays.

Reported in coffey-labs/ihasmail#35.

(cherry picked from commit 22490d8fe0190d85d8039a977435245b0d7da3cc)
2026-10-05 10:35:26 -07:00
jcoffey-dev af9b3a7f70 Indent the mark-read effect's first two lines again
Lost while resolving the conflict in the #30 pick.
2026-10-05 10:33:48 -07:00
jcoffey-dev 8a670c5e70 Turkish: the three image size strings (ihasmail #34)
(cherry picked from commit 5987f9c815d13df133841c4873b2aa9c394bc433)
2026-10-05 10:33:41 -07:00
jcoffey-dev cfb28ada45 Let mail wider than the pane scroll sideways (ihasmail #31)
(cherry picked from commit 14680d0e1b2cc966aa840a45e73b238322ed5e17)
2026-10-05 10:33:41 -07:00
jcoffey-dev 7b9da44116 Keep an open message unread after Mark as unread (ihasmail #30)
(cherry picked from commit 4a6bf677cbed9ecd23d02c0788c927e0dc9e941a)
2026-10-05 10:33:41 -07:00
jcoffey-dev 300ec1df99 Resize images in the composer (ihasmail #33)
(cherry picked from commit f17bdc9fff1455a734e19fdd8004fcd6b7970825)
2026-10-05 10:33:31 -07:00
jcoffey-dev 3f19f82960 Add Turkish translation (contributed by Hakan Arslan) (ihasmail #32)
(cherry picked from commit 017b1b5eb812ba18c3577ff79c544a25f7f91aaa)
2026-10-05 10:33:27 -07:00
jcoffey-dev 2e7f30c64e Finish the settings load when the tree remounts mid-load (ihasmail #29)
(cherry picked from commit 5ec06f40125cc320fba64edaa4ee1a1fb23d22d2)
2026-10-05 10:33:08 -07:00
jcoffey-dev c558693db5 Merge pull request 'ci: let the announce workflow run by hand for a given tag' (#40) from ci/announce-dispatch into main
ci / node (push) Skipped
ci / version (push) Skipped
ci / docker-build (push) Skipped
ci / publish (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 2m25s
ci / announce (push) Skipped
2026-10-05 03:56:29 +00:00
jcoffey-dev c87a6d1cd6 ci: let the announce workflow run by hand for a given tag
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m7s
ci / announce (pull_request) Skipped
A release event runs the workflow as it was at the tag, so re-running a
failed announcement repeats the failure even after main is fixed. A manual
run takes the tag and uses main's workflow; the action already accepts it.
2026-10-04 20:53:46 -07:00
jcoffey-dev 34d3f21b4b Merge pull request 'ci: pin discourse-release at 9282c6f, which maps inbuxa-webmail' (#39) from ci/announce-pin-inbuxa-webmail into main
ci / node (push) Skipped
ci / version (push) Skipped
ci / docker-build (push) Skipped
ci / publish (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 1m55s
ci / announce (push) Skipped
2026-10-05 03:49:11 +00:00
jcoffey-dev 0c5ad7a3b8 ci: pin discourse-release at 9282c6f, which maps inbuxa-webmail
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m26s
ci / announce (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / node (pull_request) Skipped
The release map knew this repo only by its old name, so announcing
inbuxa-v2026.10.5-g17a8093 failed. The action itself is unchanged.
2026-10-04 20:46:27 -07:00
27 changed files with 3208 additions and 36 deletions

No files matched your search

+10 -1
View File
@@ -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 }}
+1 -1
View File
@@ -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 }}
+1 -1
View File
@@ -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
View File
@@ -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;
}; };
+55 -8
View File
@@ -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);
}); });
}); });
+1
View File
@@ -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
View File
@@ -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);
+13
View File
@@ -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/);
});
});
+4 -1
View File
@@ -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%; }
+3
View File
@@ -812,6 +812,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ß",
+3
View File
@@ -808,6 +808,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",
+3
View File
@@ -814,6 +814,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",
+3
View File
@@ -808,6 +808,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": "特大",
+3
View File
@@ -809,6 +809,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",
+729
View File
@@ -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",
},
};
+3
View File
@@ -811,6 +811,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",
+3
View File
@@ -811,6 +811,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": "Очень крупный",
File diff suppressed because it is too large. Load diff
+3
View File
@@ -805,6 +805,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": "Дуже великий",
+3
View File
@@ -807,6 +807,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": "特大",
+13
View File
@@ -1475,6 +1475,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 +1627,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; }
+194
View File
@@ -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>
);
}
+17
View File
@@ -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;
@@ -37,6 +38,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 +48,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 +76,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]);
@@ -205,6 +211,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 +235,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,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();
});
});
+3 -1
View File
@@ -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>
) : ( ) : (
<> <>
+17 -1
View File
@@ -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,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);
});
});