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

This commit was merged in pull request #41.
This commit is contained in:
jcoffey-dev committed 2026-10-05 17:43:08 +00:00
commit 3434a5ed39
25 files changed
+3197 -34

No files matched your search

+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.
- **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
View File
@@ -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;
};
+55 -8
View File
@@ -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);
});
});
+1
View File
@@ -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
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. */
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);
+13
View File
@@ -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/);
});
});
+4 -1
View File
@@ -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%; }
+3
View File
@@ -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ß",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -808,6 +808,9 @@ export const catalog: Catalog = {
"Text size": "文字サイズ",
"Font size": "フォントサイズ",
"Small": "小",
"Original size": "元のサイズ",
"Drag to resize": "ドラッグしてサイズを変更",
"Image size": "画像のサイズ",
"Medium": "中",
"Large": "大",
"Huge": "特大",
+3
View File
@@ -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",
+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",
"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",
+3
View File
@@ -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
+3
View File
@@ -805,6 +805,9 @@ export const catalog: Catalog = {
"Text size": "Розмір тексту",
"Font size": "Розмір шрифту",
"Small": "Дрібний",
"Original size": "Початковий розмір",
"Drag to resize": "Перетягніть, щоб змінити розмір",
"Image size": "Розмір зображення",
"Medium": "Середній",
"Large": "Великий",
"Huge": "Дуже великий",
+3
View File
@@ -807,6 +807,9 @@ export const catalog: Catalog = {
"Text size": "文字大小",
"Font size": "字号",
"Small": "小",
"Original size": "原始大小",
"Drag to resize": "拖动以调整大小",
"Image size": "图片大小",
"Medium": "中",
"Large": "大",
"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-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; }
+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 { 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();
});
});
+3 -1
View File
@@ -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>
) : (
<>
+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);
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);
});
});