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
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
This commit was merged in pull request #41.
This commit is contained in:
commit
3434a5ed39
25 files changed
+3197
-34
No files matched your search
@@ -35,7 +35,7 @@ settings included, lives on the server, so the container is disposable.
|
||||
Everything else is in INBUXA Admin.
|
||||
- **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.
|
||||
- **Ten interface languages and twelve themes.**
|
||||
- **Eleven interface languages and twelve themes.**
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
+13
-12
@@ -19,7 +19,7 @@ import { ComposerDock } from "@/views/compose/ComposerDock";
|
||||
import { requestNotificationPermission, setBaseTitle, setUnreadBadge } from "@/lib/notify/notify";
|
||||
import { publishWorkerFacts } from "@/lib/sw/swFacts";
|
||||
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 { listenForVerification, renewWebPush } from "@/lib/notify/webpushEnable";
|
||||
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
|
||||
* language version, so picking a language throws it away and builds it
|
||||
* 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);
|
||||
useEffect(() => {
|
||||
if (settingsAlreadyLoadedFor(accountId)) {
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
let canceled = false;
|
||||
void (async () => {
|
||||
void loadSettingsOnce(accountId, async (isCurrent) => {
|
||||
/* Before the account's own settings, so both the seeding below and the
|
||||
enforcement inside `hydrate` have something to apply. */
|
||||
await loadSettingsPolicy();
|
||||
if (canceled) return;
|
||||
if (!isCurrent()) return;
|
||||
const remote = await loadRemoteSettings();
|
||||
if (canceled) return;
|
||||
if (!isCurrent()) return;
|
||||
if (remote) useSettings.getState().hydrate(remote);
|
||||
// No settings file: this account has never had settings of its own, so
|
||||
// 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
|
||||
// asks for it; this is waiting for the answer.
|
||||
await whenLanguageReady();
|
||||
if (canceled) return;
|
||||
setReady(true);
|
||||
if (!isCurrent()) return;
|
||||
// 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.
|
||||
armSettingsSync();
|
||||
// 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.
|
||||
if (!remote && settingsSyncAvailable()) queueSettingsPush(syncedPart(useSettings.getState().settings));
|
||||
})();
|
||||
}).then(() => {
|
||||
if (!canceled) setReady(true);
|
||||
});
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, mergeRemote, syncedPart, type Settings } from "@/store/settings";
|
||||
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
|
||||
@@ -116,20 +116,67 @@ describe("a change made but not yet written up", () => {
|
||||
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();
|
||||
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.
|
||||
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
|
||||
// than trusting whatever the previous session left behind.
|
||||
stopSettingsSync();
|
||||
expect(settingsAlreadyLoadedFor("a1")).toBe(false);
|
||||
await loadSettingsOnce("a1", load);
|
||||
expect(loads).toBe(2);
|
||||
stopSettingsSync();
|
||||
});
|
||||
|
||||
it("treats a missing account as already loaded, so nothing is fetched", () => {
|
||||
expect(settingsAlreadyLoadedFor(null)).toBe(true);
|
||||
expect(settingsAlreadyLoadedFor(undefined)).toBe(true);
|
||||
it("finishes a load that a remount interrupts, so changes are saved (Gitea #23)", async () => {
|
||||
stopSettingsSync();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => { release = r; });
|
||||
let loads = 0;
|
||||
let armed = false;
|
||||
const load = async (isCurrent: () => boolean) => {
|
||||
loads++;
|
||||
await gate;
|
||||
if (!isCurrent()) return;
|
||||
armed = true;
|
||||
};
|
||||
// First mount starts the load; the settings file picks a language and the
|
||||
// tree remounts before the load has finished.
|
||||
const first = loadSettingsOnce("a1", load);
|
||||
const second = loadSettingsOnce("a1", load);
|
||||
expect(second).toBe(first);
|
||||
release();
|
||||
await second;
|
||||
expect(loads).toBe(1);
|
||||
// This is what used to be skipped: the remounted tree took the
|
||||
// already-loaded path and nothing armed the pushes.
|
||||
expect(armed).toBe(true);
|
||||
stopSettingsSync();
|
||||
});
|
||||
|
||||
it("stops a load that a sign-out overtakes", async () => {
|
||||
stopSettingsSync();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => { release = r; });
|
||||
let applied = false;
|
||||
const pending = loadSettingsOnce("a1", async (isCurrent) => {
|
||||
await gate;
|
||||
if (isCurrent()) applied = true;
|
||||
});
|
||||
stopSettingsSync();
|
||||
release();
|
||||
await pending;
|
||||
expect(applied).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a missing account as already loaded, so nothing is fetched", async () => {
|
||||
let loads = 0;
|
||||
await loadSettingsOnce(null, async () => { loads++; });
|
||||
await loadSettingsOnce(undefined, async () => { loads++; });
|
||||
expect(loads).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,7 @@ export const UI_LANGUAGES: readonly UiLanguage[] = [
|
||||
{ tag: "ru", name: "Русский" },
|
||||
{ tag: "uk", name: "Українська" },
|
||||
{ tag: "zh-Hans", name: "简体中文" },
|
||||
{ tag: "tr", name: "Türkçe" },
|
||||
];
|
||||
|
||||
/** Where to report a bad translation. Beta languages depend on it. */
|
||||
|
||||
+28
-10
@@ -34,6 +34,9 @@ let inFlight: Promise<void> | null = null;
|
||||
/** Nothing is pushed before the first load has settled, or we would race it. */
|
||||
let armed = false;
|
||||
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;
|
||||
|
||||
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
|
||||
* read. The subtree that does the reading is keyed on the language version
|
||||
* and so is deliberately remounted whenever somebody picks a language;
|
||||
* without this the remount re-reads a file written before the change and
|
||||
* applies it, putting the old language back.
|
||||
* The subtree that does the reading is keyed on the language version, so it
|
||||
* is remounted whenever the language changes -- including when the settings
|
||||
* file itself picks one. Tying the load to a mount meant that remount canceled
|
||||
* it halfway: the new mount saw the account already claimed and skipped the
|
||||
* 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.
|
||||
*/
|
||||
export function settingsAlreadyLoadedFor(accountId: string | null | undefined): boolean {
|
||||
if (!accountId) return true;
|
||||
if (loadedFor === accountId) return true;
|
||||
export function loadSettingsOnce(
|
||||
accountId: string | null | undefined,
|
||||
load: (isCurrent: () => boolean) => Promise<void>,
|
||||
): Promise<void> {
|
||||
if (!accountId) return Promise.resolve();
|
||||
if (loadedFor === accountId && loading) return loading;
|
||||
loadedFor = accountId;
|
||||
return false;
|
||||
const mine = ++generation;
|
||||
loading = load(() => generation === mine);
|
||||
return loading;
|
||||
}
|
||||
|
||||
/** Allow pushes. Called once the first load has settled, either way. */
|
||||
@@ -93,6 +109,8 @@ export function armSettingsSync(): void {
|
||||
export function stopSettingsSync(): void {
|
||||
armed = false;
|
||||
loadedFor = null;
|
||||
loading = null;
|
||||
generation++;
|
||||
pending = null;
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer);
|
||||
|
||||
@@ -387,3 +387,16 @@ describe("the containment that mail CSS cannot override", () => {
|
||||
expect(rule).toMatch(/contain\s*:\s*layout/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mail wider than the reading pane", () => {
|
||||
/*
|
||||
* The root keeps `contain: content`, which clips what overflows, so without
|
||||
* a scroll the right side of a min-width table was simply gone, worst on a
|
||||
* phone (Gitea issue #24). jsdom does no layout, so this pins the rule.
|
||||
*/
|
||||
it("scrolls sideways instead of being cut off", () => {
|
||||
const root = EMAIL_BASE_CSS.match(/\.ihm-email-root \{([^}]*)\}/)?.[1] ?? "";
|
||||
expect(root).toMatch(/contain:\s*content/);
|
||||
expect(root).toMatch(/overflow-x:\s*auto/);
|
||||
});
|
||||
});
|
||||
@@ -287,7 +287,10 @@ export function sanitizeEditorHtml(input: string): string {
|
||||
export const EMAIL_BASE_CSS = `
|
||||
:host { display:block; color-scheme: light; }
|
||||
: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[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%; }
|
||||
|
||||
@@ -812,6 +812,9 @@ export const catalog: Catalog = {
|
||||
"Text size": "Schriftgröße",
|
||||
"Font size": "Schriftgröße",
|
||||
"Small": "Klein",
|
||||
"Original size": "Originalgröße",
|
||||
"Drag to resize": "Zum Ändern der Größe ziehen",
|
||||
"Image size": "Bildgröße",
|
||||
"Medium": "Mittel",
|
||||
"Large": "Groß",
|
||||
"Huge": "Sehr groß",
|
||||
|
||||
@@ -808,6 +808,9 @@ export const catalog: Catalog = {
|
||||
"Text size": "Tamaño del texto",
|
||||
"Font size": "Tamaño de letra",
|
||||
"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",
|
||||
"Large": "Grande",
|
||||
"Huge": "Muy grande",
|
||||
|
||||
@@ -814,6 +814,9 @@ export const catalog: Catalog = {
|
||||
"Text size": "Taille du texte",
|
||||
"Font size": "Taille de police",
|
||||
"Small": "Petite",
|
||||
"Original size": "Taille d’origine",
|
||||
"Drag to resize": "Faites glisser pour redimensionner",
|
||||
"Image size": "Taille de l’image",
|
||||
"Medium": "Moyenne",
|
||||
"Large": "Grande",
|
||||
"Huge": "Très grande",
|
||||
|
||||
@@ -808,6 +808,9 @@ export const catalog: Catalog = {
|
||||
"Text size": "文字サイズ",
|
||||
"Font size": "フォントサイズ",
|
||||
"Small": "小",
|
||||
"Original size": "元のサイズ",
|
||||
"Drag to resize": "ドラッグしてサイズを変更",
|
||||
"Image size": "画像のサイズ",
|
||||
"Medium": "中",
|
||||
"Large": "大",
|
||||
"Huge": "特大",
|
||||
|
||||
@@ -809,6 +809,9 @@ export const catalog: Catalog = {
|
||||
"Text size": "Tekstgrootte",
|
||||
"Font size": "Lettergrootte",
|
||||
"Small": "Klein",
|
||||
"Original size": "Oorspronkelijke grootte",
|
||||
"Drag to resize": "Sleep om het formaat te wijzigen",
|
||||
"Image size": "Afbeeldingsgrootte",
|
||||
"Medium": "Middel",
|
||||
"Large": "Groot",
|
||||
"Huge": "Zeer groot",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -811,6 +811,9 @@ export const catalog: Catalog = {
|
||||
"Text size": "Tamanho do texto",
|
||||
"Font size": "Tamanho da fonte",
|
||||
"Small": "Pequeno",
|
||||
"Original size": "Tamanho original",
|
||||
"Drag to resize": "Arraste para redimensionar",
|
||||
"Image size": "Tamanho da imagem",
|
||||
"Medium": "Médio",
|
||||
"Large": "Grande",
|
||||
"Huge": "Muito grande",
|
||||
|
||||
@@ -811,6 +811,9 @@ export const catalog: Catalog = {
|
||||
"Text size": "Размер текста",
|
||||
"Font size": "Размер шрифта",
|
||||
"Small": "Мелкий",
|
||||
"Original size": "Исходный размер",
|
||||
"Drag to resize": "Перетащите, чтобы изменить размер",
|
||||
"Image size": "Размер изображения",
|
||||
"Medium": "Средний",
|
||||
"Large": "Крупный",
|
||||
"Huge": "Очень крупный",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -805,6 +805,9 @@ export const catalog: Catalog = {
|
||||
"Text size": "Розмір тексту",
|
||||
"Font size": "Розмір шрифту",
|
||||
"Small": "Дрібний",
|
||||
"Original size": "Початковий розмір",
|
||||
"Drag to resize": "Перетягніть, щоб змінити розмір",
|
||||
"Image size": "Розмір зображення",
|
||||
"Medium": "Середній",
|
||||
"Large": "Великий",
|
||||
"Huge": "Дуже великий",
|
||||
|
||||
@@ -807,6 +807,9 @@ export const catalog: Catalog = {
|
||||
"Text size": "文字大小",
|
||||
"Font size": "字号",
|
||||
"Small": "小",
|
||||
"Original size": "原始大小",
|
||||
"Drag to resize": "拖动以调整大小",
|
||||
"Image size": "图片大小",
|
||||
"Medium": "中",
|
||||
"Large": "大",
|
||||
"Huge": "特大",
|
||||
|
||||
@@ -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-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; }
|
||||
/* 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-subject { flex: 0 1 auto; }
|
||||
.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 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; }
|
||||
/* 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 .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; }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { AlignCenter, AlignLeft, AlignRight, Bold, Code, Eraser, Image as ImageI
|
||||
import { sanitizeEditorHtml } from "@/lib/text/html";
|
||||
import { Popover, useMenu } from "@/ui/popover";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
import { ImageResizer } from "./ImageResizer";
|
||||
|
||||
export interface RichEditorHandle {
|
||||
focus(): void;
|
||||
@@ -37,6 +38,8 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
||||
const linkMenu = useMenu();
|
||||
const [linkUrl, setLinkUrl] = useState("");
|
||||
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)
|
||||
useEffect(() => {
|
||||
@@ -45,6 +48,7 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
||||
if (html !== lastEmitted.current) {
|
||||
el.innerHTML = html;
|
||||
lastEmitted.current = html;
|
||||
setSelectedImg(null);
|
||||
setEmpty(!el.textContent?.trim() && !el.querySelector("img"));
|
||||
}
|
||||
}, [html]);
|
||||
@@ -72,6 +76,8 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
||||
if (!el) return;
|
||||
const v = el.innerHTML;
|
||||
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"));
|
||||
onChange(v);
|
||||
}, [onChange]);
|
||||
@@ -205,6 +211,14 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
||||
onMouseUp={saveRange}
|
||||
onPaste={onPaste}
|
||||
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()}
|
||||
onKeyDown={(e) => {
|
||||
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-label={translate("Message body")}
|
||||
/>
|
||||
{selectedImg && elRef.current && (
|
||||
<ImageResizer area={elRef.current} img={selectedImg} onChange={emit} onClose={() => setSelectedImg(null)} />
|
||||
)}
|
||||
{showToolbar && (
|
||||
<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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -795,6 +795,9 @@ function RowView({ email: e, threadEmails, top, height, selected, focused, open,
|
||||
<span className="truncate">{who}</span>
|
||||
{count > 1 && <span className="thread-count"> {count}</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">
|
||||
{hasAtt && <Paperclip size={14} className="msg-attach" />}
|
||||
<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"} />
|
||||
</button>
|
||||
</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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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);
|
||||
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
|
||||
const lastId = messages[messages.length - 1]?.id;
|
||||
const isExpanded = useCallback(
|
||||
@@ -137,7 +153,7 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
|
||||
const readOnly = useViewingDelegation()?.access === "read";
|
||||
useEffect(() => {
|
||||
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 (markTimer.current) window.clearTimeout(markTimer.current);
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user