Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2174ccb7b3 | ||
|
|
0580812216 | ||
|
|
06ea28dade | ||
|
|
20ac83c8df | ||
|
|
a58ea326d9 | ||
|
|
9fcf4812f3 | ||
|
|
13c9b8ef91 | ||
|
|
6b979c5ac7 | ||
|
|
8acd30e8a1 | ||
|
|
5f27923ce6 | ||
|
|
68c91a1ad4 | ||
|
|
9e77fb3f47 | ||
|
|
351a5aa01d | ||
|
|
34baca4365 | ||
|
|
9f53759462 | ||
|
|
c66a8aadd7 | ||
|
|
7f12a7d8ee | ||
|
|
722a68432c | ||
|
|
e94508ea89 | ||
|
|
6190d2b4f5 | ||
|
|
ed23610e31 | ||
|
|
3e044057f6 | ||
|
|
69ccb313e3 | ||
|
|
29795f3988 | ||
|
|
38dcbd9e0c | ||
|
|
5fe929c6a4 | ||
|
|
c2502d36bd | ||
|
|
b11f3997d4 | ||
|
|
f6320e6614 | ||
|
|
152311b035 | ||
|
|
3434a5ed39 | ||
|
|
64b5db01f3 | ||
|
|
af9b3a7f70 | ||
|
|
8a670c5e70 | ||
|
|
cfb28ada45 | ||
|
|
7b9da44116 | ||
|
|
300ec1df99 | ||
|
|
3f19f82960 | ||
|
|
2e7f30c64e | ||
|
|
c558693db5 | ||
|
|
c87a6d1cd6 | ||
|
|
34d3f21b4b | ||
|
|
0c5ad7a3b8 |
No files matched your search
@@ -1,17 +1,26 @@
|
||||
# Announce each published release on the community forum, in this project's
|
||||
# Announcements category (coffey-labs/actions discourse-release; the repo ->
|
||||
# category map is its release-map.json). Safe to re-run: one topic per tag.
|
||||
# Run it by hand with a tag to announce a release whose own run failed: a
|
||||
# release event runs the workflow as it was at the tag, so a fix on main only
|
||||
# reaches an old release this way.
|
||||
name: announce
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: release tag to announce, e.g. inbuxa-v2026.10.5-g17a8093
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
announce:
|
||||
runs-on: light
|
||||
steps:
|
||||
- uses: coffey-labs/actions/discourse-release@e9293996e2efa770839121fa8f8da93083f216be
|
||||
- uses: coffey-labs/actions/discourse-release@9282c6f27303f4eb9d61d6aee6daa444c11d7268
|
||||
with:
|
||||
api-key: ${{ secrets.DISCOURSE_RELEASE_KEY }}
|
||||
discord-webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
||||
tag: ${{ inputs.tag }}
|
||||
@@ -203,7 +203,7 @@ jobs:
|
||||
if: ${{ always() && startsWith(github.ref, 'refs/tags/inbuxa-v') && (needs.publish.result == 'success' || needs.github.result == 'success') }}
|
||||
runs-on: light
|
||||
steps:
|
||||
- uses: coffey-labs/actions/discourse-release@e9293996e2efa770839121fa8f8da93083f216be
|
||||
- uses: coffey-labs/actions/discourse-release@9282c6f27303f4eb9d61d6aee6daa444c11d7268
|
||||
with:
|
||||
api-key: ${{ secrets.DISCOURSE_RELEASE_KEY }}
|
||||
discord-webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
## Translations
|
||||
|
||||
<!--
|
||||
Nine languages ship alongside English, and a missing key silently renders
|
||||
Ten languages ship alongside English, and a missing key silently renders
|
||||
its English source -- so an untranslated string is invisible until somebody
|
||||
reading that language finds it. Say which this PR is, explicitly:
|
||||
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ representative at an online or offline event.
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
**johnellisATlinuxDOTcom**.
|
||||
**communityATcoffeylabsDOTorg**.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
|
||||
+6
-5
@@ -73,13 +73,14 @@ start it, and neither will closing and reopening.
|
||||
|
||||
### Translations
|
||||
|
||||
Nine languages ship alongside English: German, Spanish, French, Dutch,
|
||||
Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, in
|
||||
Ten languages ship alongside English: German, Spanish, French, Dutch,
|
||||
Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese, Japanese and
|
||||
Turkish, in
|
||||
`web/src/locales/`. A missing key renders its English source rather than
|
||||
failing, so an untranslated string is invisible until somebody reading that
|
||||
language finds it.
|
||||
|
||||
**Any change that adds or alters a user-visible string adds work in all nine
|
||||
**Any change that adds or alters a user-visible string adds work in all ten
|
||||
catalogs.** Say so explicitly in the PR — how many keys, and the fallback
|
||||
count before and after — and say so just as explicitly when a change adds none,
|
||||
so it is never left to be inferred.
|
||||
@@ -94,7 +95,7 @@ plural(n, { one: "Deleted {n} contact", other: "Deleted {n} contacts" })
|
||||
|
||||
is keyed on **`"Deleted {n} contacts"`**. Keying the catalog on the `one`
|
||||
form type-checks, builds, passes every test, and silently falls back to English
|
||||
in all nine languages. Nothing errors. The only signal is the fallback count
|
||||
in all ten languages. Nothing errors. The only signal is the fallback count
|
||||
going up, so read it:
|
||||
|
||||
```sh
|
||||
@@ -212,7 +213,7 @@ and the single-host `deploy.example.sh`, are covered in
|
||||
|
||||
## Reporting Security Issues
|
||||
|
||||
Please **do not** open a public issue for security vulnerabilities. Instead, report them privately by emailing **johnellisATlinuxDOTcom** with details of the issue. See `SECURITY.md` if one is present in the repo for further instructions.
|
||||
Please **do not** open a public issue for security vulnerabilities. Instead, report them privately by emailing **securityATcoffeylabsDOTorg** with details of the issue. See `SECURITY.md` if one is present in the repo for further instructions.
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -107,4 +107,4 @@ keep up. Nothing here is pushed there.
|
||||
|
||||
## License
|
||||
|
||||
Copyright (C) 2026 Coffey Labs. AGPL-3.0-or-later; see [LICENSE](LICENSE).
|
||||
Copyright (C) 2026 Coffey Labs LLC. AGPL-3.0-or-later; see [LICENSE](LICENSE).
|
||||
+1
-1
@@ -15,7 +15,7 @@ ihasmail is under active development. Security fixes are applied to the latest r
|
||||
|
||||
Instead, report security issues privately by emailing:
|
||||
|
||||
**johnellisATlinuxDOTcom**
|
||||
**securityATcoffeylabsDOTorg**
|
||||
|
||||
Please include as much of the following as you can:
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* inbuxa MA-B: more than one account signed in in one browser. The session
|
||||
* cookie is the account in front; the others ride in `<name>_more`. Adding
|
||||
* signs a second account in beside the first, switching swaps them, signing
|
||||
* out ends only the one in front, and an organization that doesn't allow it
|
||||
* keeps adding off.
|
||||
*/
|
||||
|
||||
const PORT = 18801;
|
||||
process.env.MOCK_PORT = String(PORT);
|
||||
process.env.MOCK_USER = "[email protected]";
|
||||
process.env.MOCK_PASS = "first-password";
|
||||
process.env.MOCK_SECOND_USER = "[email protected]";
|
||||
process.env.MOCK_SECOND_PASS = "second-password";
|
||||
process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
|
||||
process.env.APP_SECRET = "test-secret-for-accounts";
|
||||
// Every test here signs in several times from one address
|
||||
process.env.LOGIN_RATE_LIMIT = "100";
|
||||
|
||||
const mock = await import("./mock/index.js");
|
||||
const { createApp } = await import("./app.js");
|
||||
const { config } = await import("./config.js");
|
||||
const { MAX_ACCOUNTS, parseOthers, serializeOthers } = await import("./accounts.js");
|
||||
|
||||
const app = createApp();
|
||||
const FRONT = config.cookieName;
|
||||
const MORE = `${config.cookieName}_more`;
|
||||
|
||||
after(() => {
|
||||
(mock as { server?: { close(): void } }).server?.close();
|
||||
});
|
||||
|
||||
/** A browser's cookie jar, as far as these two cookies go. */
|
||||
class Browser {
|
||||
jar = new Map<string, string>();
|
||||
|
||||
private take(res: Response) {
|
||||
for (const line of res.headers.getSetCookie()) {
|
||||
const [pair, ...attrs] = line.split(";");
|
||||
const at = pair!.indexOf("=");
|
||||
const name = pair!.slice(0, at).trim();
|
||||
const value = pair!.slice(at + 1).trim();
|
||||
const expired = attrs.some((a) => /max-age=0/i.test(a) || /expires=thu, 01 jan 1970/i.test(a));
|
||||
if (expired || !value) this.jar.delete(name);
|
||||
else this.jar.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
async call(path: string, init: { method?: string; body?: unknown } = {}): Promise<{ status: number; body: any }> {
|
||||
const cookie = [...this.jar].map(([k, v]) => `${k}=${v}`).join("; ");
|
||||
const res = await app.request(path, {
|
||||
method: init.method ?? "GET",
|
||||
headers: { "content-type": "application/json", "x-requested-with": "ihasmail", ...(cookie ? { cookie } : {}) },
|
||||
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
|
||||
});
|
||||
this.take(res);
|
||||
const text = await res.text();
|
||||
return { status: res.status, body: text ? JSON.parse(text) : null };
|
||||
}
|
||||
|
||||
signIn(username: string, password: string, add = false) {
|
||||
return this.call("/api/auth/login", { method: "POST", body: { username, password, ...(add ? { add: true } : {}) } });
|
||||
}
|
||||
|
||||
async accounts(): Promise<{ username: string; front: boolean; id: string }[]> {
|
||||
const res = await this.call("/api/auth/accounts");
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body));
|
||||
return res.body.accounts;
|
||||
}
|
||||
}
|
||||
|
||||
test("the cookie list keeps only well-formed session cookies, at most one fewer than the cap", () => {
|
||||
const good = "abcdefghij.ABCDEFGHIJKLMN";
|
||||
assert.deepEqual(parseOthers(`${good}~not a cookie~${good}`), [good]);
|
||||
const many = Array.from({ length: 9 }, (_, i) => `abcdefgh${i}x.ABCDEFGHIJKLMN`);
|
||||
assert.equal(parseOthers(many.join("~")).length, MAX_ACCOUNTS - 1);
|
||||
assert.equal(serializeOthers(["bad", good]), good);
|
||||
});
|
||||
|
||||
test("a second account joins the first, and switching swaps them", async () => {
|
||||
const b = new Browser();
|
||||
assert.equal((await b.signIn("[email protected]", "first-password")).status, 200);
|
||||
assert.deepEqual((await b.accounts()).map((a) => a.username), ["[email protected]"]);
|
||||
const first = b.jar.get(FRONT);
|
||||
|
||||
const added = await b.signIn("[email protected]", "second-password", true);
|
||||
assert.equal(added.status, 200, JSON.stringify(added.body));
|
||||
assert.equal(added.body.added, true);
|
||||
assert.equal(b.jar.get(MORE), first, "the first account moved beside the new one");
|
||||
let accounts = await b.accounts();
|
||||
assert.deepEqual(accounts.map((a) => [a.username, a.front]), [["[email protected]", true], ["[email protected]", false]]);
|
||||
|
||||
// The same account again is not a second copy
|
||||
await b.signIn("[email protected]", "first-password", true);
|
||||
accounts = await b.accounts();
|
||||
assert.equal(accounts.length, 2);
|
||||
assert.equal(accounts[0]!.username, "[email protected]", "it came to the front instead");
|
||||
|
||||
// Switch back
|
||||
const second = accounts.find((a) => !a.front)!;
|
||||
assert.equal((await b.call(`/api/auth/accounts/${second.id}/front`, { method: "POST" })).status, 200);
|
||||
assert.equal((await b.accounts())[0]!.username, "[email protected]");
|
||||
|
||||
// Signing out ends only the one in front; the other comes forward
|
||||
const out = await b.call("/api/auth/logout", { method: "POST" });
|
||||
assert.equal(out.body.next, true);
|
||||
assert.deepEqual((await b.accounts()).map((a) => a.username), ["[email protected]"]);
|
||||
|
||||
// Sign out of all
|
||||
await b.signIn("[email protected]", "second-password", true);
|
||||
assert.equal((await b.accounts()).length, 2);
|
||||
await b.call("/api/auth/logout-all", { method: "POST" });
|
||||
assert.equal(b.jar.has(FRONT), false);
|
||||
assert.equal(b.jar.has(MORE), false);
|
||||
assert.equal((await b.call("/api/auth/accounts")).status, 401);
|
||||
});
|
||||
|
||||
test("an account in front can't switch to one it doesn't hold", async () => {
|
||||
const b = new Browser();
|
||||
await b.signIn("[email protected]", "first-password");
|
||||
assert.equal((await b.call("/api/auth/accounts/not-a-session/front", { method: "POST" })).status, 404);
|
||||
});
|
||||
|
||||
test("an organization that doesn't allow it keeps adding off", async () => {
|
||||
const b = new Browser();
|
||||
await b.signIn("[email protected]", "first-password");
|
||||
process.env.MOCK_NO_ADD_ACCOUNTS = "1";
|
||||
try {
|
||||
// The cached upstream session is a minute old at most; ask afresh
|
||||
await b.call("/api/auth/session?refresh=1");
|
||||
const res = await b.call("/api/auth/accounts");
|
||||
assert.equal(res.body.canAdd, false);
|
||||
const added = await b.signIn("[email protected]", "second-password", true);
|
||||
assert.equal(added.status, 403);
|
||||
assert.equal(added.body.error, "add_not_allowed");
|
||||
assert.deepEqual((await b.accounts()).map((a) => a.username), ["[email protected]"], "the front stayed");
|
||||
} finally {
|
||||
delete process.env.MOCK_NO_ADD_ACCOUNTS;
|
||||
}
|
||||
});
|
||||
|
||||
test("inbuxa MA-8: the accounts not in front report their Inbox unread count", async () => {
|
||||
const b = new Browser();
|
||||
await b.signIn("[email protected]", "first-password");
|
||||
// Alone, there is nothing to report
|
||||
assert.deepEqual((await b.call("/api/auth/accounts/unread")).body.accounts, []);
|
||||
await b.signIn("[email protected]", "second-password", true);
|
||||
const res = await b.call("/api/auth/accounts/unread");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.accounts.length, 1, "only the account not in front");
|
||||
const [other] = res.body.accounts;
|
||||
const listed = (await b.accounts()).find((a) => !a.front)!;
|
||||
assert.equal(other.id, listed.id);
|
||||
assert.equal(typeof other.unread, "number", JSON.stringify(res.body));
|
||||
});
|
||||
|
||||
test("inbuxa MA-8: only push, mailboxes and marking mail reach an account not in front", async () => {
|
||||
const { otherAccountCallAllowed } = await import("./app.js");
|
||||
assert.equal(otherAccountCallAllowed(["PushSubscription/get", { ids: null }, "0"]), true);
|
||||
assert.equal(otherAccountCallAllowed(["Mailbox/get", { accountId: "a" }, "0"]), true);
|
||||
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { "keywords/$seen": true } } }, "0"]), true);
|
||||
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { mailboxIds: { arch: true } } } }, "0"]), true);
|
||||
// Anything else is refused
|
||||
assert.equal(otherAccountCallAllowed(["Email/get", { accountId: "a" }, "0"]), false);
|
||||
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", destroy: ["m1"] }, "0"]), false);
|
||||
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", create: { x: {} } }, "0"]), false);
|
||||
assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { subject: "x" } } }, "0"]), false);
|
||||
assert.equal(otherAccountCallAllowed(["EmailSubmission/set", {}, "0"]), false);
|
||||
|
||||
const b = new Browser();
|
||||
await b.signIn("[email protected]", "first-password");
|
||||
const added = await b.signIn("[email protected]", "second-password", true);
|
||||
assert.equal(added.status, 200, JSON.stringify(added.body));
|
||||
const other = (await b.accounts()).find((a) => !a.front)!;
|
||||
const ok = await b.call(`/api/auth/accounts/${other.id}/jmap`, {
|
||||
method: "POST",
|
||||
body: { using: ["urn:ietf:params:jmap:core"], methodCalls: [["PushSubscription/get", { ids: null }, "0"]] },
|
||||
});
|
||||
assert.equal(ok.status, 200, JSON.stringify(ok.body));
|
||||
assert.equal(ok.body.methodResponses[0][0], "PushSubscription/get");
|
||||
const refused = await b.call(`/api/auth/accounts/${other.id}/jmap`, {
|
||||
method: "POST",
|
||||
body: { using: [], methodCalls: [["Email/get", { accountId: "x", ids: null }, "0"]] },
|
||||
});
|
||||
assert.equal(refused.status, 403);
|
||||
// The account in front isn't reached this way, nor a session not held here
|
||||
const front = (await b.accounts()).find((a) => a.front)!;
|
||||
assert.equal((await b.call(`/api/auth/accounts/${front.id}/jmap`, { method: "POST", body: { methodCalls: [] } })).status, 404);
|
||||
});
|
||||
|
||||
test("inbuxa MA-8: each listed account says which mail account it is", async () => {
|
||||
const b = new Browser();
|
||||
await b.signIn("[email protected]", "first-password");
|
||||
await b.signIn("[email protected]", "second-password", true);
|
||||
const res = await b.call("/api/auth/accounts");
|
||||
for (const a of res.body.accounts) assert.equal(typeof a.mailAccountId, "string", JSON.stringify(a));
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* More than one signed-in account in a browser (multi-account spec, MA-B).
|
||||
*
|
||||
* The session cookie is unchanged: it is the account in front, and every
|
||||
* request is answered with it, so nothing else in the server has to know
|
||||
* there may be others. The others ride in a second cookie, `<name>_more`: a
|
||||
* list of their own session cookies, each `id.secret` exactly as the front one
|
||||
* is. Switching swaps one of them into front; adding moves the front one into
|
||||
* the list. Each session stays its own -- its own sealed credential, its own
|
||||
* expiry, its own "this is my device" -- and nothing about one can be read
|
||||
* through another.
|
||||
*
|
||||
* At most `MAX_ACCOUNTS` in all, all on the same mail server (MA-9), and only
|
||||
* while both accounts' organizations allow it (`addAccounts`, MA-C).
|
||||
*/
|
||||
import type { UpstreamSession } from "./upstream.js";
|
||||
|
||||
export const MAX_ACCOUNTS = 5;
|
||||
|
||||
/** A session cookie's shape: `id.secret`, both base64url. Anything else is dropped. */
|
||||
const COOKIE_SHAPE = /^[A-Za-z0-9_-]{8,128}\.[A-Za-z0-9_-]{8,256}$/;
|
||||
const SEP = "~";
|
||||
|
||||
export function parseOthers(value: string | undefined): string[] {
|
||||
if (!value) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const part of value.split(SEP)) {
|
||||
if (!COOKIE_SHAPE.test(part) || seen.has(part)) continue;
|
||||
seen.add(part);
|
||||
out.push(part);
|
||||
if (out.length >= MAX_ACCOUNTS - 1) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function serializeOthers(cookies: string[]): string {
|
||||
return cookies.filter((c) => COOKIE_SHAPE.test(c)).slice(0, MAX_ACCOUNTS - 1).join(SEP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account behind `upstream` may have other accounts beside it:
|
||||
* `addAccounts` on its own account's `urn:inbuxa:jmap` capability. A server
|
||||
* that doesn't say (an older one, or not inbuxa) allows it, as before.
|
||||
*/
|
||||
export function mayAddAccounts(upstream: UpstreamSession): boolean {
|
||||
const primary = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"] ?? Object.keys(upstream.accounts ?? {})[0];
|
||||
const account = primary ? (upstream.accounts?.[primary] as { accountCapabilities?: Record<string, unknown> } | undefined) : undefined;
|
||||
const inbuxa = account?.accountCapabilities?.["urn:inbuxa:jmap"] as { addAccounts?: unknown } | undefined;
|
||||
return inbuxa?.addAccounts !== false;
|
||||
}
|
||||
|
||||
/** Why an account can't be added, in words for the person. */
|
||||
export const ADD_REFUSED: Record<string, string> = {
|
||||
add_full: `You can have at most ${MAX_ACCOUNTS} accounts open here.`,
|
||||
add_not_allowed: "Your organization doesn't allow adding other accounts here.",
|
||||
add_other_server: "That account is on another mail server. Only accounts on this server can be added.",
|
||||
};
|
||||
+281
-2
@@ -45,6 +45,7 @@ import { SignInError, finish as finishSignIn, needsRefresh, oauthEnabled, passwo
|
||||
import { icsProxyHandler } from "./icsproxy.js";
|
||||
import { staticHandler } from "./static.js";
|
||||
import { mailNode, webmailNode } from "./nodes.js";
|
||||
import { ADD_REFUSED, MAX_ACCOUNTS, mayAddAccounts, parseOthers, serializeOthers } from "./accounts.js";
|
||||
|
||||
type Env = { Variables: { session: LiveSession } };
|
||||
|
||||
@@ -330,6 +331,146 @@ function setSessionCookie(c: Context, value: string, remember: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* inbuxa MA-B: the other signed-in accounts, beside the one in front. See
|
||||
* accounts.ts. Kept across a browser restart only when every account in it
|
||||
* would be (MA-7).
|
||||
*/
|
||||
const OTHERS_COOKIE = `${config.cookieName}_more`;
|
||||
|
||||
function setOthersCookie(c: Context, cookies: string[]) {
|
||||
if (!cookies.length) {
|
||||
deleteCookie(c, OTHERS_COOKIE, { path: cookiePath });
|
||||
return;
|
||||
}
|
||||
const remember = cookies.every((cookie) => sessions.resolve(cookie)?.remember === true);
|
||||
setCookie(c, OTHERS_COOKIE, serializeOthers(cookies), {
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
secure: isSecureRequest(c),
|
||||
path: cookiePath,
|
||||
...(remember ? { maxAge: config.sessionRememberTtl } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** The other accounts still signed in, in their order; ended ones are left out. */
|
||||
function liveOthers(c: Context): { cookie: string; session: LiveSession }[] {
|
||||
const out: { cookie: string; session: LiveSession }[] = [];
|
||||
for (const cookie of parseOthers(getCookie(c, OTHERS_COOKIE))) {
|
||||
const session = sessions.resolve(cookie);
|
||||
if (session) out.push({ cookie, session });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/*
|
||||
* inbuxa MA-8: what may be asked of a signed-in account that isn't in front.
|
||||
*
|
||||
* Its push subscription has to be registered, verified and renewed through
|
||||
* its own session -- a JMAP push subscription belongs to whoever signs the
|
||||
* request -- and a notification's Archive and Mark read act on its mail. Those
|
||||
* four methods, and for Email/set only changes to keywords and mailboxes: the
|
||||
* browser already holds the session, so this reaches nothing new, but it is
|
||||
* kept to what the notifications need.
|
||||
*/
|
||||
const OTHER_ACCOUNT_METHODS = new Set(["PushSubscription/get", "PushSubscription/set", "Mailbox/get", "Email/set"]);
|
||||
|
||||
export function otherAccountCallAllowed(call: unknown): boolean {
|
||||
if (!Array.isArray(call) || call.length !== 3) return false;
|
||||
const [method, args] = call as [unknown, unknown, unknown];
|
||||
if (typeof method !== "string" || !OTHER_ACCOUNT_METHODS.has(method)) return false;
|
||||
if (typeof args !== "object" || args === null) return false;
|
||||
if (method !== "Email/set") return true;
|
||||
const set = args as { create?: unknown; destroy?: unknown; update?: unknown };
|
||||
if (set.create !== undefined || set.destroy !== undefined) return false;
|
||||
if (typeof set.update !== "object" || set.update === null) return false;
|
||||
return Object.values(set.update as Record<string, unknown>).every(
|
||||
(patch) =>
|
||||
typeof patch === "object" &&
|
||||
patch !== null &&
|
||||
Object.keys(patch).every((k) => k === "keywords" || k === "mailboxIds" || k.startsWith("keywords/") || k.startsWith("mailboxIds/")),
|
||||
);
|
||||
}
|
||||
|
||||
/** inbuxa MA-8: Inbox unread counts of accounts not in front, briefly kept. */
|
||||
const UNREAD_CACHE_MS = 60_000;
|
||||
const unreadCache = new Map<string, { unread: number | null; at: number }>();
|
||||
|
||||
/** The Inbox's unread count for one session's account, or null when it has none. */
|
||||
async function inboxUnread(session: LiveSession): Promise<number | null> {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||
const accountId = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
||||
if (!accountId) return null;
|
||||
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
|
||||
method: "POST",
|
||||
headers: { authorization: session.authorization, "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
||||
methodCalls: [["Mailbox/get", { accountId, ids: null, properties: ["role", "unreadEmails"] }, "0"]],
|
||||
}),
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { methodResponses?: [string, { list?: { role?: string | null; unreadEmails?: number }[] }, string][] };
|
||||
const inbox = body.methodResponses?.[0]?.[1]?.list?.find((m) => m.role === "inbox");
|
||||
return typeof inbox?.unreadEmails === "number" ? inbox.unreadEmails : null;
|
||||
}
|
||||
|
||||
/** Whether the account in front may have more beside it, or why not. */
|
||||
async function addRefusal(c: Context, front: LiveSession): Promise<string | null> {
|
||||
if (1 + liveOthers(c).length >= MAX_ACCOUNTS) return "add_full";
|
||||
try {
|
||||
const upstream = await getUpstreamSession(front.id, front.authorization, upstreamFor(front.username));
|
||||
if (!mayAddAccounts(upstream)) return "add_not_allowed";
|
||||
} catch {
|
||||
return "add_not_allowed";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A session just signed in to be added beside the one in front (MA-B). It
|
||||
* comes to the front and the old front joins the others; or, refused, it is
|
||||
* ended and the front stays. Returns the refusal, or null.
|
||||
*/
|
||||
async function joinAccount(
|
||||
c: Context,
|
||||
created: { cookie: string; session: LiveSession },
|
||||
upstream: Awaited<ReturnType<typeof fetchUpstreamSession>>,
|
||||
): Promise<string | null> {
|
||||
const frontCookie = getCookie(c, config.cookieName);
|
||||
const front = sessions.resolve(frontCookie);
|
||||
if (!front || !frontCookie) {
|
||||
// Nobody in front any more: an ordinary sign-in
|
||||
setSessionCookie(c, created.cookie, created.session.remember);
|
||||
return null;
|
||||
}
|
||||
const others = liveOthers(c);
|
||||
const end = (code: string | null) => {
|
||||
sessions.destroy(created.session.id);
|
||||
forgetUpstreamSession(created.session.id);
|
||||
return code;
|
||||
};
|
||||
if (upstreamFor(created.session.username) !== upstreamFor(front.username)) return end("add_other_server");
|
||||
// Both organizations must allow it
|
||||
if (!mayAddAccounts(upstream)) return end("add_not_allowed");
|
||||
const frontRefusal = await addRefusal(c, front);
|
||||
if (frontRefusal === "add_not_allowed") return end(frontRefusal);
|
||||
// Already open: that one comes to the front instead of a second copy
|
||||
if (created.session.account === front.account) return end(null);
|
||||
const existing = others.find((o) => o.session.account === created.session.account);
|
||||
if (existing) {
|
||||
end(null);
|
||||
setSessionCookie(c, existing.cookie, existing.session.remember);
|
||||
setOthersCookie(c, [frontCookie, ...others.filter((o) => o !== existing).map((o) => o.cookie)]);
|
||||
return null;
|
||||
}
|
||||
if (1 + others.length >= MAX_ACCOUNTS) return end("add_full");
|
||||
setSessionCookie(c, created.cookie, created.session.remember);
|
||||
setOthersCookie(c, [frontCookie, ...others.map((o) => o.cookie)]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function upstreamFailure(c: Context, err: unknown) {
|
||||
if (err instanceof UpstreamError) {
|
||||
return c.json({ error: err.status === 401 ? "invalid_credentials" : "upstream_error", message: err.message }, err.status as 401 | 502);
|
||||
@@ -406,8 +547,19 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
return c.redirect(`${basePath}/?signin_error=rate_limited`, 302);
|
||||
}
|
||||
const username = (c.req.query("username") ?? "").trim().slice(0, 320);
|
||||
// inbuxa MA-B: another account beside the one in front, if it may have one
|
||||
const front = c.req.query("add") === "1" ? sessions.resolve(getCookie(c, config.cookieName)) : null;
|
||||
if (front) {
|
||||
const refused = await addRefusal(c, front);
|
||||
if (refused) return c.redirect(`${basePath}/?account_error=${refused}`, 302);
|
||||
}
|
||||
try {
|
||||
const { location, state } = await startSignIn({ username, base: upstreamFor(username), remember: c.req.query("remember") === "1" });
|
||||
const { location, state } = await startSignIn({
|
||||
username,
|
||||
base: upstreamFor(username),
|
||||
remember: c.req.query("remember") === "1",
|
||||
adding: front !== null,
|
||||
});
|
||||
setCookie(c, OAUTH_STATE_COOKIE, state, { httpOnly: true, sameSite: "Lax", secure: isSecureRequest(c), path: `${basePath}/api/auth`, maxAge: 600 });
|
||||
return c.redirect(location, 302);
|
||||
} catch (err) {
|
||||
@@ -452,6 +604,11 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
userAgent: c.req.header("user-agent") ?? "",
|
||||
ip: clientIp(c),
|
||||
});
|
||||
if (result.adding) {
|
||||
const refused = await joinAccount(c, { cookie, session }, upstream);
|
||||
if (refused) return c.redirect(`${basePath}/?account_error=${refused}`, 302);
|
||||
return c.redirect(`${basePath}/`, 302);
|
||||
}
|
||||
setSessionCookie(c, cookie, session.remember);
|
||||
const mailAccount = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
||||
if (mailAccount) pushPrepare(session.username, mailAccount, pushCredential(session));
|
||||
@@ -473,7 +630,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(rateIp)));
|
||||
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
|
||||
}
|
||||
let body: { username?: string; password?: string; totp?: string; remember?: boolean };
|
||||
let body: { username?: string; password?: string; totp?: string; remember?: boolean; add?: boolean };
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
@@ -536,6 +693,12 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
userAgent: c.req.header("user-agent") ?? "",
|
||||
ip,
|
||||
});
|
||||
// inbuxa MA-B: beside the account in front, when that's what was asked
|
||||
if (body.add && sessions.resolve(getCookie(c, config.cookieName))) {
|
||||
const refused = await joinAccount(c, { cookie, session }, upstream);
|
||||
if (refused) return c.json({ error: refused, message: ADD_REFUSED[refused] }, 403);
|
||||
return c.json({ ok: true, added: true });
|
||||
}
|
||||
setSessionCookie(c, cookie, session.remember);
|
||||
// Start the account's push subscription now, so it is usually verified
|
||||
// by the time the browser opens its stream. See push.ts.
|
||||
@@ -606,7 +769,123 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
sessions.destroy(session.id);
|
||||
forgetUpstreamSession(session.id);
|
||||
}
|
||||
// inbuxa MA-B: only this account ends; the next one comes to the front
|
||||
const [next, ...rest] = liveOthers(c);
|
||||
if (next) {
|
||||
setSessionCookie(c, next.cookie, next.session.remember);
|
||||
setOthersCookie(c, rest.map((o) => o.cookie));
|
||||
return c.json({ ok: true, next: true });
|
||||
}
|
||||
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||
deleteCookie(c, OTHERS_COOKIE, { path: cookiePath });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/* inbuxa MA-B: every account signed in here ends. */
|
||||
api.post("/auth/logout-all", async (c) => {
|
||||
const front = sessions.resolve(getCookie(c, config.cookieName));
|
||||
for (const session of [front, ...liveOthers(c).map((o) => o.session)]) {
|
||||
if (!session) continue;
|
||||
sessions.destroy(session.id);
|
||||
forgetUpstreamSession(session.id);
|
||||
}
|
||||
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||
deleteCookie(c, OTHERS_COOKIE, { path: cookiePath });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/* inbuxa MA-B: the accounts signed in here, the one in front first, and whether one more may be added. */
|
||||
api.get("/auth/accounts", requireSession, async (c) => {
|
||||
const front = c.get("session");
|
||||
const others = liveOthers(c);
|
||||
if (others.length !== parseOthers(getCookie(c, OTHERS_COOKIE)).length) {
|
||||
setOthersCookie(c, others.map((o) => o.cookie));
|
||||
}
|
||||
const canAdd = (await addRefusal(c, front)) === null;
|
||||
// inbuxa MA-8: each one's mail account, which its push subscription and
|
||||
// a notification's buttons need
|
||||
const accounts = await Promise.all(
|
||||
[front, ...others.map((o) => o.session)].map(async (s, i) => {
|
||||
let mailAccountId: string | null = null;
|
||||
try {
|
||||
const upstream = await getUpstreamSession(s.id, s.authorization, upstreamFor(s.username));
|
||||
mailAccountId = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"] ?? null;
|
||||
} catch {
|
||||
/* unknown for now: push for it waits for the next start */
|
||||
}
|
||||
return { id: s.id, username: s.username, front: i === 0, mailAccountId };
|
||||
}),
|
||||
);
|
||||
return c.json({ accounts, canAdd, max: MAX_ACCOUNTS });
|
||||
});
|
||||
|
||||
/*
|
||||
* inbuxa MA-8: the Inbox unread count of each account not in front, asked
|
||||
* through that account's own session, so the menu can say where new mail
|
||||
* is. A minute's cache per account: the web app asks every few minutes, and
|
||||
* several tabs may ask at once.
|
||||
*/
|
||||
api.get("/auth/accounts/unread", requireSession, async (c) => {
|
||||
const answers = await Promise.all(
|
||||
liveOthers(c).map(async ({ cookie, session }) => {
|
||||
const cached = unreadCache.get(session.id);
|
||||
if (cached && Date.now() - cached.at < UNREAD_CACHE_MS) return { id: session.id, unread: cached.unread };
|
||||
try {
|
||||
let live: LiveSession | null = session;
|
||||
if (live.tokens && needsRefresh(live.tokens)) live = await refreshSession(cookie, live);
|
||||
if (!live) return { id: session.id, unread: null };
|
||||
const unread = await inboxUnread(live);
|
||||
unreadCache.set(session.id, { unread, at: Date.now() });
|
||||
return { id: session.id, unread };
|
||||
} catch {
|
||||
return { id: session.id, unread: null };
|
||||
}
|
||||
}),
|
||||
);
|
||||
return c.json({ accounts: answers });
|
||||
});
|
||||
|
||||
/* inbuxa MA-8: a narrow JMAP route to a signed-in account not in front; see OTHER_ACCOUNT_METHODS. */
|
||||
api.post("/auth/accounts/:id/jmap", requireSession, async (c) => {
|
||||
const other = liveOthers(c).find((o) => o.session.id === c.req.param("id"));
|
||||
if (!other) return c.json({ error: "not_found" }, 404);
|
||||
let body: { using?: unknown; methodCalls?: unknown };
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: "bad_request" }, 400);
|
||||
}
|
||||
const calls = body.methodCalls;
|
||||
if (!Array.isArray(calls) || calls.length === 0 || calls.length > 16 || !calls.every(otherAccountCallAllowed)) {
|
||||
return c.json({ error: "forbidden", message: "Only push subscriptions, mailboxes and marking mail can be reached in another account." }, 403);
|
||||
}
|
||||
const using = Array.isArray(body.using) ? body.using.filter((u): u is string => typeof u === "string") : [];
|
||||
let session: LiveSession | null = other.session;
|
||||
if (session.tokens && needsRefresh(session.tokens)) session = await refreshSession(other.cookie, session);
|
||||
if (!session) return c.json({ error: "unauthenticated" }, 401);
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
|
||||
method: "POST",
|
||||
headers: { authorization: session.authorization, "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({ using, methodCalls: calls }),
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) return c.json({ error: "unauthenticated" }, 401);
|
||||
return c.json(await res.json(), res.ok ? 200 : 502);
|
||||
} catch (err) {
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
/* inbuxa MA-B: bring another signed-in account to the front. */
|
||||
api.post("/auth/accounts/:id/front", requireSession, async (c) => {
|
||||
const frontCookie = getCookie(c, config.cookieName)!;
|
||||
const others = liveOthers(c);
|
||||
const chosen = others.find((o) => o.session.id === c.req.param("id"));
|
||||
if (!chosen) return c.json({ error: "not_found" }, 404);
|
||||
setSessionCookie(c, chosen.cookie, chosen.session.remember);
|
||||
setOthersCookie(c, [frontCookie, ...others.filter((o) => o !== chosen).map((o) => o.cookie)]);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -23,9 +23,25 @@ function unauthorized(res: ServerResponse) {
|
||||
res.end(JSON.stringify({ type: "about:blank", status: 401, title: "Unauthorized" }));
|
||||
}
|
||||
|
||||
/*
|
||||
* inbuxa MA-B: an optional second user, so the account switcher has two real
|
||||
* accounts to move between. It signs in with a password only, and reads the
|
||||
* same mailbox: what the tests look at is who the session says it is.
|
||||
*/
|
||||
const SECOND_USER = process.env.MOCK_SECOND_USER;
|
||||
const SECOND_PASS = process.env.MOCK_SECOND_PASS;
|
||||
|
||||
/** Who a Basic header signs in as, when it is the second user. */
|
||||
function secondUser(req: IncomingMessage): boolean {
|
||||
const h = req.headers.authorization ?? "";
|
||||
if (!SECOND_USER || !h.startsWith("Basic ")) return false;
|
||||
return Buffer.from(h.slice(6), "base64").toString() === `${SECOND_USER}:${SECOND_PASS}`;
|
||||
}
|
||||
|
||||
function checkAuth(req: IncomingMessage): boolean {
|
||||
const h = req.headers.authorization ?? "";
|
||||
if (checkBearer(h)) return true;
|
||||
if (secondUser(req)) return true;
|
||||
if (!h.startsWith("Basic ") || basicRefused) return false;
|
||||
const raw = Buffer.from(h.slice(6), "base64").toString();
|
||||
const sep = raw.indexOf(":");
|
||||
@@ -83,7 +99,14 @@ export const server = createServer(async (req, res) => {
|
||||
if (!checkAuth(req)) return unauthorized(res);
|
||||
if (url.pathname === "/.well-known/jmap" || url.pathname === "/jmap/session") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify(session()));
|
||||
const answer = session() as ReturnType<typeof session> & { username: string };
|
||||
if (secondUser(req)) answer.username = SECOND_USER!;
|
||||
// MA-C: an organization that doesn't allow adding accounts
|
||||
if (process.env.MOCK_NO_ADD_ACCOUNTS === "1") {
|
||||
const own = answer.accounts[ACCOUNT] as { accountCapabilities: Record<string, unknown> };
|
||||
own.accountCapabilities = { ...own.accountCapabilities, "urn:inbuxa:jmap": { addAccounts: false } };
|
||||
}
|
||||
return res.end(JSON.stringify(answer));
|
||||
}
|
||||
// The account info endpoint; the only place a server reports its edition.
|
||||
if (url.pathname === "/api/account" && req.method === "GET") {
|
||||
|
||||
@@ -217,3 +217,23 @@ test("push keeps a credential that renews itself", async () => {
|
||||
assert.notEqual(second, first, "a fresh access token");
|
||||
assert.match(second, /^Bearer mock-at-/);
|
||||
});
|
||||
|
||||
test("inbuxa MA-B: adding an account asks the server's page to sign in again", async () => {
|
||||
// With nobody in front, add=1 is an ordinary sign-in
|
||||
let res = await call("/api/auth/oauth/[email protected]&add=1");
|
||||
assert.equal(new URL(res.headers.get("location")!).searchParams.has("prompt"), false);
|
||||
|
||||
await signIn();
|
||||
res = await call("/api/auth/oauth/[email protected]&add=1");
|
||||
assert.equal(res.status, 302);
|
||||
const signInPage = new URL(res.headers.get("location")!);
|
||||
assert.equal(signInPage.searchParams.get("prompt"), "login", "the server's page must not reuse the first sign-in");
|
||||
|
||||
// The mock's page signs the same account in again: it stays one account
|
||||
const approved = await fetch(signInPage, { redirect: "manual" });
|
||||
const back = new URL(approved.headers.get("location")!);
|
||||
res = await call(`/api/auth/callback${back.search}`);
|
||||
assert.equal(res.headers.get("location"), "/");
|
||||
const list = await jsonOf(await call("/api/auth/accounts"));
|
||||
assert.deepEqual(list.accounts.map((a: { username: string }) => a.username), ["[email protected]"]);
|
||||
});
|
||||
+9
-4
@@ -97,6 +97,8 @@ interface Pending {
|
||||
base: string;
|
||||
username: string;
|
||||
remember: boolean;
|
||||
/** MA-B: signing in a second account beside the one in front. */
|
||||
adding: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
@@ -114,13 +116,13 @@ function challengeOf(verifier: string): string {
|
||||
* Begin a sign-in. Returns where to send the browser, and the state to bind
|
||||
* to it in a cookie.
|
||||
*/
|
||||
export async function start(params: { username: string; base: string; remember: boolean }): Promise<{ location: string; state: string }> {
|
||||
export async function start(params: { username: string; base: string; remember: boolean; adding?: boolean }): Promise<{ location: string; state: string }> {
|
||||
const metadata = await metadataFor(params.base);
|
||||
sweepPending();
|
||||
if (pending.size >= MAX_PENDING) throw new UpstreamError("Too many sign-ins in progress", 503);
|
||||
const state = randomToken(24);
|
||||
const verifier = randomToken(48);
|
||||
pending.set(state, { verifier, base: params.base, username: params.username, remember: params.remember, createdAt: Date.now() });
|
||||
pending.set(state, { verifier, base: params.base, username: params.username, remember: params.remember, adding: Boolean(params.adding), createdAt: Date.now() });
|
||||
const scope = ["openid", "offline_access"].filter((s) => metadata.scopes.length === 0 || metadata.scopes.includes(s)).join(" ");
|
||||
const url = new URL(metadata.authorizationEndpoint);
|
||||
url.searchParams.set("response_type", "code");
|
||||
@@ -131,6 +133,9 @@ export async function start(params: { username: string; base: string; remember:
|
||||
url.searchParams.set("code_challenge", challengeOf(verifier));
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
if (params.username) url.searchParams.set("login_hint", params.username);
|
||||
// MA-B: ask again, rather than let the server's page reuse the sign-in of
|
||||
// the account already in front
|
||||
if (params.adding) url.searchParams.set("prompt", "login");
|
||||
return { location: url.toString(), state };
|
||||
}
|
||||
|
||||
@@ -177,7 +182,7 @@ export class SignInError extends Error {
|
||||
* Finish a sign-in: `state` as it came back in the URL, `boundState` as the
|
||||
* browser's cookie holds it. Each state is good for one attempt.
|
||||
*/
|
||||
export async function finish(params: { state: string; boundState: string | undefined; code: string }): Promise<{ tokens: TokenSet; base: string; username: string; remember: boolean }> {
|
||||
export async function finish(params: { state: string; boundState: string | undefined; code: string }): Promise<{ tokens: TokenSet; base: string; username: string; remember: boolean; adding: boolean }> {
|
||||
const p = pending.get(params.state);
|
||||
if (!p || !params.boundState || params.boundState !== params.state) {
|
||||
throw new SignInError("state_mismatch", "This sign-in didn't start in this browser. Try again.");
|
||||
@@ -192,7 +197,7 @@ export async function finish(params: { state: string; boundState: string | undef
|
||||
redirect_uri: redirectUri(),
|
||||
});
|
||||
if (!tokens) throw new SignInError("exchange_failed", "The mail server didn't accept the sign-in. Try again.");
|
||||
return { tokens, base: p.base, username: p.username, remember: p.remember };
|
||||
return { tokens, base: p.base, username: p.username, remember: p.remember, adding: p.adding };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+80
-6
@@ -386,8 +386,11 @@ async function readFacts() {
|
||||
* rather than swallowed. A tap that silently does nothing is the failure worth
|
||||
* avoiding here: the reader has already put the phone down.
|
||||
*/
|
||||
async function jmap(methodCalls) {
|
||||
const res = await fetch(`${BASE}/api/jmap`, {
|
||||
async function jmap(methodCalls, sessionId) {
|
||||
// inbuxa MA-8: an account not in front is reached through its own session,
|
||||
// on the webmail server's narrow route for it
|
||||
const path = sessionId ? `${BASE}/api/auth/accounts/${encodeURIComponent(sessionId)}/jmap` : `${BASE}/api/jmap`;
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json", accept: "application/json", "x-requested-with": "ihasmail" },
|
||||
@@ -456,6 +459,15 @@ self.addEventListener("push", (event) => {
|
||||
|
||||
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
|
||||
event.waitUntil((async () => {
|
||||
const facts = await readFacts();
|
||||
/*
|
||||
* inbuxa MA-8: whose mail this is. The payload names its account; one that
|
||||
* isn't the account in front is one of the others signed in here, or one
|
||||
* that has been signed out since (said plainly, with nothing to act on).
|
||||
*/
|
||||
const forAccount = data && data.accountId && facts && data.accountId !== facts.accountId ? data.accountId : null;
|
||||
const other = forAccount ? (facts.others || []).find((o) => o.accountId === forAccount) || null : null;
|
||||
if (forAccount) return showOtherAccount(emails, facts, other);
|
||||
/*
|
||||
* Someone reading the app already knows. A focused, visible window of this
|
||||
* app gets its new mail from its own event stream, so a notification on
|
||||
@@ -464,7 +476,6 @@ self.addEventListener("push", (event) => {
|
||||
*/
|
||||
const windows = await self.clients.matchAll({ type: "window" });
|
||||
if (windows.some((w) => w.focused && w.visibilityState === "visible")) return;
|
||||
const facts = await readFacts();
|
||||
const strings = facts?.strings ?? { newMail: "New mail", newMessage: "New message", noSubject: "(no subject)" };
|
||||
/*
|
||||
* Mark the app icon, without claiming a number.
|
||||
@@ -502,8 +513,7 @@ self.addEventListener("push", (event) => {
|
||||
// be drawn.
|
||||
actions: email.id ? actionsFor(facts) : [],
|
||||
data: {
|
||||
// The route names a conversation, and `m` the message in it.
|
||||
url: email.id && email.threadId ? `${BASE}/mail/inbox/${email.threadId}?m=${encodeURIComponent(email.id)}` : `${BASE}/mail`,
|
||||
url: messageUrl(facts && facts.inboxId, email),
|
||||
id: email.id || null,
|
||||
title,
|
||||
accountId: facts?.accountId ?? null,
|
||||
@@ -515,6 +525,70 @@ self.addEventListener("push", (event) => {
|
||||
})());
|
||||
});
|
||||
|
||||
/*
|
||||
* inbuxa MA-8: new mail for a signed-in account that isn't in front.
|
||||
*
|
||||
* Shown even while a tab is focused: that tab's own stream only carries the
|
||||
* account in front, so nothing else would tell. The account's address is the
|
||||
* title, so it can't be taken for the front account's mail; the tag carries
|
||||
* the account, so two accounts' notifications don't replace each other; the
|
||||
* buttons act through that account's session; and opening it brings that
|
||||
* account forward before showing the message.
|
||||
*/
|
||||
async function showOtherAccount(emails, facts, other) {
|
||||
const strings = facts.strings;
|
||||
const icon = `${BASE}/img/icon-192.png?v=${BRAND_V}`;
|
||||
const badge = `${BASE}/img/favicon-64.png?v=${BRAND_V}`;
|
||||
if ("setAppBadge" in self.navigator) await self.navigator.setAppBadge().catch(() => {});
|
||||
if (!other || !emails.length) {
|
||||
// Signed out since, or nothing to show: say only what is true
|
||||
await self.registration.showNotification(other ? other.username : strings.newMail, {
|
||||
body: other ? strings.newMail : undefined,
|
||||
icon, badge,
|
||||
tag: `ihasmail-other-${other ? other.accountId : "unknown"}`,
|
||||
data: { url: other ? openUrl(other, `${BASE}/mail`) : `${BASE}/mail` },
|
||||
});
|
||||
return;
|
||||
}
|
||||
for (const email of emails.slice(0, 5)) {
|
||||
const { title, body, preview } = textOf(email, strings);
|
||||
const at = messageUrl(other.inboxId, email);
|
||||
await self.registration.showNotification(other.username, {
|
||||
body: `${title}: ${body}${preview ? `\n${preview}` : ""}`,
|
||||
icon, badge,
|
||||
tag: `ihasmail-${other.accountId}-${email.id || body}`,
|
||||
actions: email.id ? actionsFor({ ...facts, archiveId: other.archiveId }) : [],
|
||||
data: {
|
||||
url: openUrl(other, at),
|
||||
id: email.id || null,
|
||||
title: other.username,
|
||||
accountId: other.accountId,
|
||||
archiveId: other.archiveId,
|
||||
sessionId: other.sessionId,
|
||||
failed: strings.failed ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Where opening a notification for an account not in front goes: it comes forward first. */
|
||||
/**
|
||||
* Where a notification opens. The route names a mailbox by id and then a
|
||||
* conversation, and `m` the message in it. It used to say `inbox` where the id
|
||||
* goes, which the app reads as a folder that no longer exists, so every click
|
||||
* landed on the inbox list with "That folder no longer exists" instead of the
|
||||
* message. Without an inbox id from the briefing, the inbox is the honest
|
||||
* landing.
|
||||
*/
|
||||
function messageUrl(inboxId, email) {
|
||||
if (!inboxId || !email.id || !email.threadId) return `${BASE}/mail`;
|
||||
return `${BASE}/mail/${encodeURIComponent(inboxId)}/${encodeURIComponent(email.threadId)}?m=${encodeURIComponent(email.id)}`;
|
||||
}
|
||||
|
||||
function openUrl(other, next) {
|
||||
return `${BASE}/?account=${encodeURIComponent(other.sessionId)}&next=${encodeURIComponent(next)}`;
|
||||
}
|
||||
|
||||
/*
|
||||
* Do what the button said, without opening anything.
|
||||
*
|
||||
@@ -535,7 +609,7 @@ async function runAction(action, data) {
|
||||
: { "keywords/$seen": true };
|
||||
try {
|
||||
if (action === "archive" && !archiveId) throw new Error("no archive mailbox");
|
||||
await jmap([["Email/set", { accountId, update: { [id]: patch } }, "0"]]);
|
||||
await jmap([["Email/set", { accountId, update: { [id]: patch } }, "0"]], data.sessionId || null);
|
||||
} catch {
|
||||
await self.registration.showNotification(data.title || "ihasmail", {
|
||||
body: data.failed || "Could not do that — open ihasmail and try again",
|
||||
|
||||
+22
-18
@@ -1,6 +1,6 @@
|
||||
import { Fragment, lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import { Route, Switch, Redirect, useLocation, Router } from "wouter";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useSession, useViewingDelegation } from "@/store/session";
|
||||
import { notifyOwnWhileAway, ownAccountAway, useMail } from "@/store/mail";
|
||||
import { scheduleSupported, useScheduled } from "@/store/scheduled";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
@@ -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;
|
||||
};
|
||||
@@ -310,12 +311,14 @@ function AuthedApp() {
|
||||
return id ? (s.mailboxes[id]?.unreadEmails ?? 0) : 0;
|
||||
});
|
||||
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
|
||||
// inbuxa AL-7: a locked account in view is named, with a padlock, in the tab
|
||||
// inbuxa AL-7: a locked account in view is named, with a padlock, in the
|
||||
// tab; a shared or group mailbox (MA-A) is named without one
|
||||
const viewingName = useSession((s) => (s.viewing ? s.session?.accounts[s.viewing]?.name : undefined));
|
||||
const lockedInView = useViewingDelegation()?.kind === "lock";
|
||||
useEffect(() => {
|
||||
setBaseTitle(viewingName ? `🔒 ${viewingName} · ${appName}` : appName);
|
||||
setBaseTitle(viewingName ? `${lockedInView ? "🔒 " : ""}${viewingName} · ${appName}` : appName);
|
||||
setUnreadBadge(inboxUnread);
|
||||
}, [inboxUnread, appName, viewingName]);
|
||||
}, [inboxUnread, appName, viewingName, lockedInView]);
|
||||
|
||||
/*
|
||||
* Leave the service worker its briefing.
|
||||
@@ -327,13 +330,14 @@ function AuthedApp() {
|
||||
* See lib/swFacts.ts.
|
||||
*/
|
||||
const archiveId = useMail((s) => s.roleId("archive"));
|
||||
const inboxId = useMail((s) => s.roleId("inbox"));
|
||||
const languageVersion = useLanguageVersion();
|
||||
useEffect(() => {
|
||||
// inbuxa AL-7: the worker acts on the reader's own mail; while a
|
||||
// delegated account is in view, the archive folder here is its
|
||||
if (viewing) return;
|
||||
void publishWorkerFacts(accountId, archiveId);
|
||||
}, [accountId, archiveId, languageVersion, viewing]);
|
||||
void publishWorkerFacts(accountId, archiveId, inboxId);
|
||||
}, [accountId, archiveId, inboxId, languageVersion, viewing]);
|
||||
|
||||
// Request notification permission lazily when enabled
|
||||
const notif = useSettings((s) => s.settings.desktopNotifications);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/** inbuxa MA-8: new mail in the accounts not in front. */
|
||||
|
||||
const shown: { title: string; opts: Record<string, unknown> }[] = [];
|
||||
vi.mock("@/lib/notify/notify", () => ({
|
||||
showNotification: (title: string, opts: Record<string, unknown>) => shown.push({ title, opts }),
|
||||
}));
|
||||
|
||||
const { risen, pollOtherUnread, useOtherUnread } = await import("@/lib/otherAccounts");
|
||||
const { useSession } = await import("@/store/session");
|
||||
const { useSettings } = await import("@/store/settings");
|
||||
|
||||
let counts: Record<string, number>;
|
||||
|
||||
beforeEach(() => {
|
||||
shown.length = 0;
|
||||
counts = { b: 3 };
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
const body = { accounts: Object.entries(counts).map(([id, unread]) => ({ id, unread })) };
|
||||
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as Response;
|
||||
}),
|
||||
);
|
||||
useOtherUnread.getState().set({});
|
||||
useSession.setState({
|
||||
signedIn: [
|
||||
{ id: "a", username: "[email protected]", front: true },
|
||||
{ id: "b", username: "[email protected]", front: false },
|
||||
],
|
||||
});
|
||||
useSettings.setState((s) => ({ settings: { ...s.settings, desktopNotifications: true } }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("other accounts' unread mail", () => {
|
||||
it("counts only a rise, and never an account seen for the first time", () => {
|
||||
expect(risen({}, { b: 4 })).toEqual([]);
|
||||
expect(risen({ b: 4 }, { b: 4 })).toEqual([]);
|
||||
expect(risen({ b: 4 }, { b: 2 })).toEqual([]);
|
||||
expect(risen({ b: 4, c: 1 }, { b: 5, c: 1 })).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("keeps the counts, and names the account when new mail arrives", async () => {
|
||||
await pollOtherUnread();
|
||||
expect(useOtherUnread.getState().unread).toEqual({ b: 3 });
|
||||
expect(shown).toEqual([]);
|
||||
|
||||
counts = { b: 5 };
|
||||
await pollOtherUnread();
|
||||
expect(shown).toHaveLength(1);
|
||||
expect(shown[0]!.title).toContain("[email protected]");
|
||||
expect(shown[0]!.opts.tag).toBe("other-account-b");
|
||||
});
|
||||
|
||||
it("stays quiet when desktop notifications are off", async () => {
|
||||
useSettings.setState((s) => ({ settings: { ...s.settings, desktopNotifications: false } }));
|
||||
await pollOtherUnread();
|
||||
counts = { b: 9 };
|
||||
await pollOtherUnread();
|
||||
expect(shown).toEqual([]);
|
||||
expect(useOtherUnread.getState().unread).toEqual({ b: 9 });
|
||||
});
|
||||
|
||||
it("leaves telling to the worker where background notifications are on", async () => {
|
||||
const { setDeviceTrusted } = await import("@/lib/storage");
|
||||
const { setPushEnabledHere } = await import("@/lib/notify/webpush");
|
||||
setDeviceTrusted(true);
|
||||
setPushEnabledHere(true);
|
||||
try {
|
||||
await pollOtherUnread();
|
||||
counts = { b: 12 };
|
||||
await pollOtherUnread();
|
||||
expect(shown).toEqual([]);
|
||||
expect(useOtherUnread.getState().unread).toEqual({ b: 12 });
|
||||
} finally {
|
||||
setPushEnabledHere(false);
|
||||
setDeviceTrusted(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,12 @@ export type DelegationAccess = "read" | "organize" | "full";
|
||||
|
||||
export interface Delegation {
|
||||
locked: boolean;
|
||||
/**
|
||||
* A locked account, or a shared mailbox such as support@ (MA-S). Both are
|
||||
* reached as a delegate at an access level; only how they are shown
|
||||
* differs. Absent from older servers, where it is always a lock.
|
||||
*/
|
||||
kind: "lock" | "sharedMailbox";
|
||||
access: DelegationAccess;
|
||||
sendAs: boolean;
|
||||
/** UTC date the delegation ends, if it does. */
|
||||
@@ -38,19 +44,20 @@ export function delegationOf(session: SessionLike | null, accountId: Id | null):
|
||||
const access: DelegationAccess = raw.access === "organize" || raw.access === "full" ? raw.access : "read";
|
||||
return {
|
||||
locked: true,
|
||||
kind: raw.kind === "sharedMailbox" ? "sharedMailbox" : "lock",
|
||||
access,
|
||||
sendAs: raw.sendAs === true && access !== "read",
|
||||
until: typeof raw.until === "string" ? raw.until : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Every locked account handed to the reader, by name. */
|
||||
/** Every locked account handed to the reader, by name. Shared mailboxes are listed by lib/sharedMail. */
|
||||
export function delegatedAccounts(session: SessionLike | null): DelegatedAccount[] {
|
||||
if (!session) return [];
|
||||
return Object.entries(session.accounts)
|
||||
.map(([id, account]) => {
|
||||
const delegation = delegationOf(session, id);
|
||||
return delegation ? { id, name: account.name, delegation } : null;
|
||||
return delegation?.kind === "lock" ? { id, name: account.name, delegation } : null;
|
||||
})
|
||||
.filter((a): a is DelegatedAccount => a !== null)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { otherAccountCall } from "@/lib/notify/otherAccount";
|
||||
import { listSubscriptions } from "@/lib/notify/webpush";
|
||||
|
||||
/** inbuxa MA-8: push calls made as an account that isn't in front. */
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function stub(response: unknown) {
|
||||
const seen: { url: string; body: any }[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
seen.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) });
|
||||
return { ok: true, status: 200, json: async () => response, text: async () => JSON.stringify(response) } as Response;
|
||||
}),
|
||||
);
|
||||
return seen;
|
||||
}
|
||||
|
||||
describe("otherAccountCall", () => {
|
||||
it("goes through that account's route and answers the method's result", async () => {
|
||||
const seen = stub({ methodResponses: [["PushSubscription/get", { list: [{ id: "p1", deviceClientId: "d" }] }, "0"]] });
|
||||
const subs = await listSubscriptions(otherAccountCall("sess-2"));
|
||||
expect(subs.map((s) => s.id)).toEqual(["p1"]);
|
||||
expect(seen[0]!.url).toContain("/api/auth/accounts/sess-2/jmap");
|
||||
expect(seen[0]!.body.methodCalls[0][0]).toBe("PushSubscription/get");
|
||||
expect(seen[0]!.body.using).toContain("urn:ietf:params:jmap:core");
|
||||
});
|
||||
|
||||
it("turns a JMAP error into a thrown one", async () => {
|
||||
stub({ methodResponses: [["error", { type: "forbidden" }, "0"]] });
|
||||
await expect(listSubscriptions(otherAccountCall("sess-2"))).rejects.toThrow("forbidden");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { clearSignedInData, setDeviceTrusted } from "@/lib/storage";
|
||||
import { deviceClientId, registeredEndpoint, rememberEndpoint, unsubscribeAccount, type JmapCall } from "@/lib/notify/webpush";
|
||||
|
||||
/** inbuxa MA-8 part 2: every signed-in account on this device has its own push subscription. */
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
describe("push for every signed-in account", () => {
|
||||
it("remembers each account's endpoint, and keeps them through a switch", () => {
|
||||
rememberEndpoint("https://push.example/old");
|
||||
// An account with nothing of its own yet reads the endpoint from before
|
||||
expect(registeredEndpoint("acc-a")).toBe("https://push.example/old");
|
||||
rememberEndpoint("https://push.example/a", "acc-a");
|
||||
rememberEndpoint("https://push.example/b", "acc-b");
|
||||
expect(registeredEndpoint("acc-a")).toBe("https://push.example/a");
|
||||
expect(registeredEndpoint("acc-b")).toBe("https://push.example/b");
|
||||
localStorage.setItem("ihasmail:cached-mail", "x");
|
||||
clearSignedInData();
|
||||
expect(registeredEndpoint("acc-a")).toBe("https://push.example/a");
|
||||
expect(localStorage.getItem("ihasmail:cached-mail")).toBeNull();
|
||||
rememberEndpoint(null, "acc-a");
|
||||
expect(localStorage.getItem("ihasmail:pushEndpoint:acc-a")).toBeNull();
|
||||
});
|
||||
|
||||
it("removes only this device's subscription from one account", async () => {
|
||||
// A device id is kept only where the device is trusted
|
||||
setDeviceTrusted(true);
|
||||
const mine = deviceClientId();
|
||||
const destroyed: string[] = [];
|
||||
const call: JmapCall = async <T,>(method: string, args: Record<string, unknown>) => {
|
||||
if (method === "PushSubscription/get") {
|
||||
return { list: [{ id: "p1", deviceClientId: mine }, { id: "p2", deviceClientId: "ihasmail-other-phone" }] } as T;
|
||||
}
|
||||
destroyed.push(...((args.destroy as string[]) ?? []));
|
||||
return {} as T;
|
||||
};
|
||||
rememberEndpoint("https://push.example/a", "acc-a");
|
||||
await unsubscribeAccount(call, "acc-a");
|
||||
expect(destroyed).toEqual(["p1"]);
|
||||
expect(localStorage.getItem("ihasmail:pushEndpoint:acc-a")).toBeNull();
|
||||
setDeviceTrusted(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* inbuxa MA-8: JMAP calls made as a signed-in account that isn't in front,
|
||||
* through the webmail server's narrow route for it
|
||||
* (POST /api/auth/accounts/<sessionId>/jmap). The server allows only what
|
||||
* notifications need: push subscriptions, mailboxes, and marking or filing
|
||||
* mail.
|
||||
*/
|
||||
import { apiFetch, CAP } from "@/jmap/client";
|
||||
import type { JmapCall } from "@/lib/notify/webpush";
|
||||
|
||||
export function otherAccountCall(sessionId: string): JmapCall {
|
||||
return async <T,>(method: string, args: Record<string, unknown>, using: string[]): Promise<T> => {
|
||||
const res = await apiFetch<{ methodResponses?: [string, unknown, string][] }>(
|
||||
`/api/auth/accounts/${encodeURIComponent(sessionId)}/jmap`,
|
||||
{ method: "POST", body: JSON.stringify({ using: [...new Set([CAP.core, ...using])], methodCalls: [[method, args, "0"]] }) },
|
||||
);
|
||||
const [name, out] = res.methodResponses?.[0] ?? [];
|
||||
if (!name) throw new Error("The mail server sent no response.");
|
||||
if (name === "error") throw new Error(String((out as { type?: string } | undefined)?.type ?? "error"));
|
||||
return out as T;
|
||||
};
|
||||
}
|
||||
@@ -22,6 +22,15 @@ import type { GetResponse, Id, SetResponse } from "@/jmap/types";
|
||||
import { isDeviceTrusted } from "@/lib/storage";
|
||||
|
||||
export const VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid";
|
||||
|
||||
/**
|
||||
* Who a push call is made as (inbuxa MA-8). A JMAP push subscription belongs
|
||||
* to whoever signs the request, so registering one for an account that isn't
|
||||
* in front goes through that account's own session (lib/notify/otherAccount).
|
||||
* Everything here defaults to the account in front.
|
||||
*/
|
||||
export type JmapCall = <T>(method: string, args: Record<string, unknown>, using: string[]) => Promise<T>;
|
||||
export const frontCall: JmapCall = (method, args, using) => client.call(method, args, using);
|
||||
export const EMAILPUSH_CAP = "urn:ietf:params:jmap:emailpush";
|
||||
|
||||
/**
|
||||
@@ -285,13 +294,13 @@ export function needsRenewal(subs: JmapPushSubscription[], deviceId: string, now
|
||||
return at - now <= RENEW_WITHIN_MS;
|
||||
}
|
||||
|
||||
export async function listSubscriptions(): Promise<JmapPushSubscription[]> {
|
||||
const res = await client.call<GetResponse<JmapPushSubscription>>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]);
|
||||
export async function listSubscriptions(call: JmapCall = frontCall): Promise<JmapPushSubscription[]> {
|
||||
const res = await call<GetResponse<JmapPushSubscription>>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]);
|
||||
return res.list;
|
||||
}
|
||||
|
||||
export async function createSubscription(body: Record<string, unknown>): Promise<Id | null> {
|
||||
const res = await client.call<SetResponse<JmapPushSubscription>>(
|
||||
export async function createSubscription(body: Record<string, unknown>, call: JmapCall = frontCall): Promise<Id | null> {
|
||||
const res = await call<SetResponse<JmapPushSubscription>>(
|
||||
"PushSubscription/set",
|
||||
{ create: { s: body } },
|
||||
[CAP.core, VAPID_CAP, EMAILPUSH_CAP],
|
||||
@@ -307,16 +316,16 @@ export async function createSubscription(body: Record<string, unknown>): Promise
|
||||
* Seven days is JMAP's ceiling and what Stalwart grants a new one; the server
|
||||
* may shorten what is asked for, and whatever it keeps is what counts.
|
||||
*/
|
||||
export async function extendSubscription(id: Id, now: number = Date.now()): Promise<void> {
|
||||
export async function extendSubscription(id: Id, now: number = Date.now(), call: JmapCall = frontCall): Promise<void> {
|
||||
const expires = new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString().replace(/\.\d+Z$/, "Z");
|
||||
const res = await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { update: { [id]: { expires } } }, [CAP.core, VAPID_CAP]);
|
||||
const res = await call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { update: { [id]: { expires } } }, [CAP.core, VAPID_CAP]);
|
||||
const err = res.notUpdated?.[id];
|
||||
if (err) throw new PushSetError(String(err.type), String(err.description ?? err.type));
|
||||
}
|
||||
|
||||
export async function destroySubscriptions(ids: Id[]): Promise<void> {
|
||||
export async function destroySubscriptions(ids: Id[], call: JmapCall = frontCall): Promise<void> {
|
||||
if (!ids.length) return;
|
||||
await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: ids }, [CAP.core, VAPID_CAP]);
|
||||
await call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: ids }, [CAP.core, VAPID_CAP]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,18 +337,29 @@ export async function destroySubscriptions(ids: Id[]): Promise<void> {
|
||||
*/
|
||||
const ENDPOINT_KEY = "ihasmail:pushEndpoint";
|
||||
|
||||
export function registeredEndpoint(): string | null {
|
||||
/*
|
||||
* inbuxa MA-8: one per account, since each signed-in account on this device
|
||||
* has its own subscription, and kept when switching between them (see
|
||||
* KEEP_ON_SIGN_OUT in lib/storage) so a switch doesn't register them afresh.
|
||||
* Without an account, the key from before: the account in front.
|
||||
*/
|
||||
function endpointKey(accountId?: Id | null): string {
|
||||
return accountId ? `${ENDPOINT_KEY}:${accountId}` : ENDPOINT_KEY;
|
||||
}
|
||||
|
||||
export function registeredEndpoint(accountId?: Id | null): string | null {
|
||||
try {
|
||||
return localStorage.getItem(ENDPOINT_KEY);
|
||||
return localStorage.getItem(endpointKey(accountId)) ?? (accountId ? localStorage.getItem(ENDPOINT_KEY) : null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberEndpoint(endpoint: string | null): void {
|
||||
export function rememberEndpoint(endpoint: string | null, accountId?: Id | null): void {
|
||||
try {
|
||||
if (endpoint) localStorage.setItem(ENDPOINT_KEY, endpoint);
|
||||
else localStorage.removeItem(ENDPOINT_KEY);
|
||||
const key = endpointKey(accountId);
|
||||
if (endpoint) localStorage.setItem(key, endpoint);
|
||||
else localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* private mode: every start is then a fresh registration, which still works */
|
||||
}
|
||||
@@ -353,8 +373,8 @@ export function rememberEndpoint(endpoint: string | null): void {
|
||||
* the client echoes it. A subscription left unverified looks registered and is
|
||||
* silent, which is the confusing failure worth being explicit about.
|
||||
*/
|
||||
export async function verifySubscription(id: Id, verificationCode: string): Promise<void> {
|
||||
const res = await client.call<SetResponse<JmapPushSubscription>>(
|
||||
export async function verifySubscription(id: Id, verificationCode: string, call: JmapCall = frontCall): Promise<void> {
|
||||
const res = await call<SetResponse<JmapPushSubscription>>(
|
||||
"PushSubscription/set",
|
||||
{ update: { [id]: { verificationCode } } },
|
||||
[CAP.core, VAPID_CAP],
|
||||
@@ -367,6 +387,20 @@ export async function destroySubscription(id: Id): Promise<void> {
|
||||
await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: [id] }, [CAP.core, VAPID_CAP]);
|
||||
}
|
||||
|
||||
/**
|
||||
* inbuxa MA-8: remove this device's subscription in one account only, leaving
|
||||
* the browser's push subscription and the switch alone -- for signing out of
|
||||
* the account in front while others stay signed in and keep notifying.
|
||||
*/
|
||||
export async function unsubscribeAccount(call: JmapCall = frontCall, accountId?: Id | null): Promise<void> {
|
||||
try {
|
||||
await destroySubscriptions(mySubscriptions(await listSubscriptions(call), deviceClientId()).map((s) => s.id), call);
|
||||
} catch {
|
||||
/* signing out must not fail over this */
|
||||
}
|
||||
rememberEndpoint(null, accountId);
|
||||
}
|
||||
|
||||
/** Remove every subscription this browser registered. Used when signing out. */
|
||||
export async function unsubscribeThisDevice(): Promise<void> {
|
||||
const mine = deviceClientId();
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
* testable: everything here touches the browser's service worker and
|
||||
* permission prompt, none of which exists under a test runner.
|
||||
*/
|
||||
import { CAP } from "@/jmap/client";
|
||||
import { apiFetch, CAP } from "@/jmap/client";
|
||||
import type { GetResponse, Id, Mailbox } from "@/jmap/types";
|
||||
import { otherAccountCall } from "@/lib/notify/otherAccount";
|
||||
import { setOtherAccountFacts, type OtherAccountFacts } from "@/lib/sw/swFacts";
|
||||
import type { SignedInAccount } from "@/store/session";
|
||||
import { withBase } from "../basePath";
|
||||
import { SW_CACHE_NAME } from "../sw/swCache";
|
||||
import { isDeviceTrusted } from "@/lib/storage";
|
||||
@@ -18,6 +22,8 @@ import {
|
||||
destroySubscriptions,
|
||||
deviceClientId,
|
||||
extendSubscription,
|
||||
frontCall,
|
||||
type JmapCall,
|
||||
findSubscription,
|
||||
listSubscriptions,
|
||||
mySubscriptions,
|
||||
@@ -29,6 +35,7 @@ import {
|
||||
roomToMake,
|
||||
setPushEnabledHere,
|
||||
subscriptionPayload,
|
||||
unsubscribeAccount,
|
||||
unsubscribeThisDevice,
|
||||
verifySubscription,
|
||||
webPushAvailable,
|
||||
@@ -48,7 +55,7 @@ export function listenForVerification(): void {
|
||||
listening = true;
|
||||
navigator.serviceWorker.addEventListener("message", (e: MessageEvent) => {
|
||||
const d = e.data as { type?: string; id?: string; code?: string } | undefined;
|
||||
if (d?.type === "push-verification" && d.id && d.code) void verifySubscription(d.id, d.code).catch(() => {});
|
||||
if (d?.type === "push-verification" && d.id && d.code) void verifyAnywhere(d.id, d.code);
|
||||
});
|
||||
void collectStoredVerification();
|
||||
}
|
||||
@@ -64,12 +71,44 @@ async function collectStoredVerification(): Promise<void> {
|
||||
if (!hit) return;
|
||||
const { id, code } = (await hit.json()) as { id?: string; code?: string };
|
||||
await cache.delete(key);
|
||||
if (id && code) await verifySubscription(id, code);
|
||||
if (id && code) await verifyAnywhere(id, code);
|
||||
} catch {
|
||||
/* nothing waiting, or no cache: not a failure */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* inbuxa MA-8: a verification code belongs to one account's subscription, and
|
||||
* the worker doesn't say which: the account in front first, then each other
|
||||
* signed-in account until one takes it.
|
||||
*/
|
||||
async function verifyAnywhere(id: Id, code: string): Promise<void> {
|
||||
try {
|
||||
await verifySubscription(id, code);
|
||||
return;
|
||||
} catch {
|
||||
/* not the front account's */
|
||||
}
|
||||
for (const account of await otherAccounts()) {
|
||||
try {
|
||||
await verifySubscription(id, code, otherAccountCall(account.id));
|
||||
return;
|
||||
} catch {
|
||||
/* not this one's either */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The signed-in accounts not in front, as the server lists them now. */
|
||||
async function otherAccounts(): Promise<SignedInAccount[]> {
|
||||
try {
|
||||
const answer = await apiFetch<{ accounts: SignedInAccount[] }>("/api/auth/accounts");
|
||||
return answer.accounts.filter((a) => !a.front);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe this browser. Safe to call again: see `registerThisBrowser`.
|
||||
*
|
||||
@@ -97,6 +136,8 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso
|
||||
await registerThisBrowser(key);
|
||||
setPushEnabledHere(true);
|
||||
listenForVerification();
|
||||
// inbuxa MA-8: and every other account signed in here
|
||||
await registerOtherAccounts(key);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: (err as Error).message || "Could not subscribe to notifications." };
|
||||
@@ -128,7 +169,23 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso
|
||||
* gave up there, leaving push off for good with the switch still saying it was
|
||||
* on.
|
||||
*/
|
||||
async function registerThisBrowser(key: string): Promise<void> {
|
||||
interface PushTarget {
|
||||
call: JmapCall;
|
||||
/** The account's mail account, which the subscription names. */
|
||||
accountId: Id | null;
|
||||
inboxId: Id | null;
|
||||
/** Whose remembered endpoint to compare with: the account's own (MA-8). */
|
||||
endpointOf: Id | null;
|
||||
}
|
||||
|
||||
function frontTarget(): PushTarget {
|
||||
// inbuxa AL-7: the reader's own inbox, never a delegated account's in view
|
||||
const accountId = useSession.getState().ownAccountFor(CAP.mail);
|
||||
return { call: frontCall, accountId, inboxId: ownInboxId(), endpointOf: accountId };
|
||||
}
|
||||
|
||||
async function registerThisBrowser(key: string, target: PushTarget = frontTarget()): Promise<void> {
|
||||
const { call } = target;
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = (await reg.pushManager.getSubscription()) ?? (await reg.pushManager.subscribe({
|
||||
// Web Push requires it, and Chrome refuses a subscription without it.
|
||||
@@ -136,35 +193,61 @@ async function registerThisBrowser(key: string): Promise<void> {
|
||||
applicationServerKey: decodeApplicationServerKey(key),
|
||||
}));
|
||||
const deviceId = deviceClientId();
|
||||
const subs = await listSubscriptions();
|
||||
const subs = await listSubscriptions(call);
|
||||
const mine = mySubscriptions(subs, deviceId);
|
||||
const [newest, ...extra] = mine;
|
||||
|
||||
if (newest && registeredEndpoint() === sub.endpoint) {
|
||||
if (extra.length) await destroySubscriptions(extra.map((s) => s.id));
|
||||
if (newest && registeredEndpoint(target.endpointOf) === sub.endpoint) {
|
||||
if (extra.length) await destroySubscriptions(extra.map((s) => s.id), call);
|
||||
const at = newest.expires ? Date.parse(newest.expires) : Number.NaN;
|
||||
if (!newest.expires || (!Number.isNaN(at) && at - Date.now() > RENEW_WITHIN_MS)) return;
|
||||
try {
|
||||
await extendSubscription(newest.id);
|
||||
await extendSubscription(newest.id, Date.now(), call);
|
||||
return;
|
||||
} catch {
|
||||
/* not extendable: replaced below */
|
||||
}
|
||||
}
|
||||
|
||||
if (mine.length) await destroySubscriptions(mine.map((s) => s.id));
|
||||
// inbuxa AL-7: the reader's own inbox, never a delegated account's in view
|
||||
const payload = subscriptionPayload(sub, useSession.getState().ownAccountFor(CAP.mail), ownInboxId());
|
||||
if (mine.length) await destroySubscriptions(mine.map((s) => s.id), call);
|
||||
const payload = subscriptionPayload(sub, target.accountId, target.inboxId);
|
||||
try {
|
||||
await createSubscription(payload);
|
||||
await createSubscription(payload, call);
|
||||
} catch (err) {
|
||||
if (!(err instanceof PushSetError) || err.type !== "overQuota") throw err;
|
||||
const room = roomToMake(subs.filter((s) => !mine.includes(s)), deviceId);
|
||||
if (!room.length) throw err;
|
||||
await destroySubscriptions(room);
|
||||
await createSubscription(payload);
|
||||
await destroySubscriptions(room, call);
|
||||
await createSubscription(payload, call);
|
||||
}
|
||||
rememberEndpoint(sub.endpoint);
|
||||
rememberEndpoint(sub.endpoint, target.endpointOf);
|
||||
}
|
||||
|
||||
/**
|
||||
* inbuxa MA-8: register this browser in every other account signed in here,
|
||||
* each through its own session, and tell the worker who they are so their
|
||||
* notifications say whose they are and their buttons act on the right mail.
|
||||
* One account failing doesn't stop the rest; the next start tries it again.
|
||||
*/
|
||||
async function registerOtherAccounts(key: string): Promise<void> {
|
||||
const facts: OtherAccountFacts[] = [];
|
||||
for (const account of await otherAccounts()) {
|
||||
if (!account.mailAccountId) continue;
|
||||
const call = otherAccountCall(account.id);
|
||||
try {
|
||||
const boxes = await call<GetResponse<Mailbox>>(
|
||||
"Mailbox/get",
|
||||
{ accountId: account.mailAccountId, ids: null, properties: ["role"] },
|
||||
[CAP.mail],
|
||||
);
|
||||
const roleId = (role: string) => boxes.list.find((m) => m.role === role)?.id ?? null;
|
||||
await registerThisBrowser(key, { call, accountId: account.mailAccountId, inboxId: roleId("inbox"), endpointOf: account.mailAccountId });
|
||||
facts.push({ accountId: account.mailAccountId, sessionId: account.id, username: account.username, archiveId: roleId("archive"), inboxId: roleId("inbox") });
|
||||
} catch {
|
||||
/* this one waits for the next start */
|
||||
}
|
||||
}
|
||||
await setOtherAccountFacts(facts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,16 +273,26 @@ export async function renewWebPush(): Promise<void> {
|
||||
// subscription is close to expiring, missing, or duplicated.
|
||||
await registerThisBrowser(key);
|
||||
listenForVerification();
|
||||
await registerOtherAccounts(key);
|
||||
} catch {
|
||||
/* offline, or the server said no: the next start tries again */
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove this browser's subscription, at the browser and at the server. */
|
||||
/** Remove this browser's subscription, at the browser and at the server, in every signed-in account. */
|
||||
export async function disableWebPush(): Promise<void> {
|
||||
await unsubscribeOtherAccounts();
|
||||
await unsubscribeThisDevice();
|
||||
}
|
||||
|
||||
/** inbuxa MA-8: remove this device's subscription from every account not in front. */
|
||||
export async function unsubscribeOtherAccounts(): Promise<void> {
|
||||
for (const account of await otherAccounts()) {
|
||||
await unsubscribeAccount(otherAccountCall(account.id), account.mailAccountId);
|
||||
}
|
||||
await setOtherAccountFacts([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether *this browser* has a subscription registered at the server.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* inbuxa MA-8: new mail in the accounts not in front.
|
||||
*
|
||||
* The webmail server answers each one's Inbox unread count through that
|
||||
* account's own session (/api/auth/accounts/unread). The menu shows the counts;
|
||||
* when one rises while the app is open, a desktop notification names the
|
||||
* account, so mail for support@ isn't missed while someone works in their own.
|
||||
*
|
||||
* Only while a tab is open, and only where background notifications are off:
|
||||
* with them on, each account has its own Web Push subscription and the worker
|
||||
* notifies (lib/notify/webpushEnable, public/sw.js).
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { create } from "zustand";
|
||||
import { apiFetch } from "@/jmap/client";
|
||||
import { showNotification } from "@/lib/notify/notify";
|
||||
import { pushEnabledHere } from "@/lib/notify/webpush";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useSettings } from "@/store/settings";
|
||||
|
||||
/** How often to ask. The server keeps each answer a minute. */
|
||||
export const POLL_MS = 2 * 60_000;
|
||||
|
||||
interface OtherUnreadState {
|
||||
/** Unread count per session id; absent until first asked, or when unknown. */
|
||||
unread: Record<string, number>;
|
||||
set(unread: Record<string, number>): void;
|
||||
}
|
||||
|
||||
export const useOtherUnread = create<OtherUnreadState>((set) => ({
|
||||
unread: {},
|
||||
set: (unread) => set({ unread }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Which accounts gained unread mail since the last answer. An account seen for
|
||||
* the first time is not "new": its mail was already there when it was added.
|
||||
*/
|
||||
export function risen(before: Record<string, number>, after: Record<string, number>): string[] {
|
||||
return Object.entries(after)
|
||||
.filter(([id, n]) => id in before && n > before[id]!)
|
||||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
export async function pollOtherUnread(): Promise<void> {
|
||||
const answer = await apiFetch<{ accounts: { id: string; unread: number | null }[] }>("/api/auth/accounts/unread");
|
||||
const next: Record<string, number> = {};
|
||||
for (const a of answer.accounts) if (typeof a.unread === "number") next[a.id] = a.unread;
|
||||
const before = useOtherUnread.getState().unread;
|
||||
useOtherUnread.getState().set(next);
|
||||
if (!useSettings.getState().settings.desktopNotifications) return;
|
||||
// With background notifications on here, the worker tells about these
|
||||
// accounts already (MA-8 part 2): the counts stay, a second telling doesn't
|
||||
if (pushEnabledHere()) return;
|
||||
const names = new Map(useSession.getState().signedIn.map((a) => [a.id, a.username]));
|
||||
for (const id of risen(before, next)) {
|
||||
const name = names.get(id);
|
||||
if (!name) continue;
|
||||
showNotification(t("New mail for {name}", { name }), {
|
||||
body: t("Unread in the Inbox: {count}", { count: next[id]! }),
|
||||
tag: `other-account-${id}`,
|
||||
onClick: () => void useSession.getState().switchTo(id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps the counts fresh while more than one account is signed in. */
|
||||
export function useOtherAccountsUnread(): void {
|
||||
const others = useSession((s) => s.signedIn.filter((a) => !a.front).length);
|
||||
useEffect(() => {
|
||||
if (others === 0) {
|
||||
useOtherUnread.getState().set({});
|
||||
return;
|
||||
}
|
||||
const tick = () => void pollOtherUnread().catch(() => undefined);
|
||||
tick();
|
||||
const timer = setInterval(tick, POLL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [others]);
|
||||
}
|
||||
+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);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Mail other people let the reader into (multi-account spec, MA-A): a group's
|
||||
* mailbox, folders someone shared, or a shared mailbox an administrator
|
||||
* assigned them to (MA-S).
|
||||
*
|
||||
* Either one arrives as another account in the session, `isPersonal: false`.
|
||||
* That alone proves nothing about mail -- the server advertises every
|
||||
* capability on any account it lists, so a colleague who shared one calendar
|
||||
* shows up with mail too. What does prove it is asking: an account whose
|
||||
* `Mailbox/get` answers with at least one mailbox has mail the reader can
|
||||
* open, and only those are offered.
|
||||
*
|
||||
* A shared mailbox needs no asking: the server marks it, as a delegation of
|
||||
* kind `sharedMailbox`, and it is mail by definition. A locked account handed
|
||||
* to the reader (AL-7) is listed by `delegation.ts` instead, and left out
|
||||
* here so it is never offered twice.
|
||||
*/
|
||||
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import type { GetResponse, Id, JmapSession, Mailbox } from "@/jmap/types";
|
||||
import { delegationOf } from "@/lib/delegation";
|
||||
|
||||
export interface SharedMailAccount {
|
||||
id: Id;
|
||||
name: string;
|
||||
}
|
||||
|
||||
type SessionLike = Pick<JmapSession, "accounts">;
|
||||
|
||||
/** Accounts that might hold mail for the reader, by name; see the note above. */
|
||||
export function sharedMailCandidates(session: SessionLike | null): SharedMailAccount[] {
|
||||
if (!session) return [];
|
||||
return Object.entries(session.accounts)
|
||||
.filter(([id, account]) => account.isPersonal === false && CAP.mail in (account.accountCapabilities ?? {}) && delegationOf(session, id)?.kind !== "lock")
|
||||
.map(([id, account]) => ({ id, name: account.name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** The candidates that answer with at least one mailbox. One that fails is left out. */
|
||||
export async function findSharedMail(session: SessionLike | null): Promise<SharedMailAccount[]> {
|
||||
const candidates = sharedMailCandidates(session);
|
||||
const answers = await Promise.all(
|
||||
candidates.map((account) =>
|
||||
delegationOf(session, account.id)?.kind === "sharedMailbox"
|
||||
? Promise.resolve(account)
|
||||
: client.call<GetResponse<Mailbox>>("Mailbox/get", { accountId: account.id, ids: null, properties: ["id"] }).then(
|
||||
(res) => (res.list.length > 0 ? account : null),
|
||||
() => null,
|
||||
),
|
||||
),
|
||||
);
|
||||
return answers.filter((a): a is SharedMailAccount => a !== null);
|
||||
}
|
||||
@@ -87,6 +87,9 @@ function ownKeys(): string[] {
|
||||
export function clearSignedInData(): void {
|
||||
for (const key of ownKeys()) {
|
||||
if (KEEP_ON_SIGN_OUT.includes(key)) continue;
|
||||
// inbuxa MA-8: each account's registered push endpoint, which is not mail
|
||||
// and is cleared with its subscription (webpush.ts)
|
||||
if (key.startsWith("pushEndpoint:")) continue;
|
||||
removeKey(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,14 @@ describe("the worker's briefing", () => {
|
||||
expect(facts.archiveId).toBe("mb-archive");
|
||||
});
|
||||
|
||||
it("names the inbox a notification opens in", async () => {
|
||||
// The route takes a mailbox id. The worker used to put the word `inbox`
|
||||
// there, and every click landed on "That folder no longer exists".
|
||||
const { store } = fakeCaches();
|
||||
await publishWorkerFacts("a1", "mb-archive", "mb-inbox");
|
||||
expect(written(store).inboxId).toBe("mb-inbox");
|
||||
});
|
||||
|
||||
it("carries the worker's text in the language the tab is in", async () => {
|
||||
// The worker has no catalog. Everything it will say has to be said here
|
||||
// first, or a German reader gets English buttons on their lock screen.
|
||||
|
||||
@@ -28,6 +28,13 @@ export interface WorkerFacts {
|
||||
accountId: string;
|
||||
/** Where Archive files to; null where the account has no archive folder. */
|
||||
archiveId: string | null;
|
||||
/** The inbox a notification opens in; the route names a mailbox by id. */
|
||||
inboxId?: string | null;
|
||||
/**
|
||||
* inbuxa MA-8: the other accounts signed in here, so a push for one of them
|
||||
* says whose it is and its buttons act through that account's session.
|
||||
*/
|
||||
others?: OtherAccountFacts[];
|
||||
/** The worker's own user-visible text, in the language this tab is in. */
|
||||
strings: {
|
||||
newMail: string;
|
||||
@@ -47,11 +54,31 @@ export interface WorkerFacts {
|
||||
* reading in a week's time. Rewriting it is one cache put; there is nothing to
|
||||
* gain by working out whether it differs.
|
||||
*/
|
||||
export async function publishWorkerFacts(accountId: string | null, archiveId: string | null): Promise<void> {
|
||||
export interface OtherAccountFacts {
|
||||
accountId: string;
|
||||
sessionId: string;
|
||||
username: string;
|
||||
archiveId: string | null;
|
||||
inboxId?: string | null;
|
||||
}
|
||||
|
||||
let lastFront: { accountId: string | null; archiveId: string | null; inboxId: string | null } = { accountId: null, archiveId: null, inboxId: null };
|
||||
let others: OtherAccountFacts[] = [];
|
||||
|
||||
/** inbuxa MA-8: record the other accounts and write the briefing again with them. */
|
||||
export async function setOtherAccountFacts(list: OtherAccountFacts[]): Promise<void> {
|
||||
others = list;
|
||||
await publishWorkerFacts(lastFront.accountId, lastFront.archiveId, lastFront.inboxId);
|
||||
}
|
||||
|
||||
export async function publishWorkerFacts(accountId: string | null, archiveId: string | null, inboxId: string | null = null): Promise<void> {
|
||||
lastFront = { accountId, archiveId, inboxId };
|
||||
if (typeof caches === "undefined" || !accountId) return;
|
||||
const facts: WorkerFacts = {
|
||||
accountId,
|
||||
archiveId,
|
||||
inboxId,
|
||||
others,
|
||||
strings: {
|
||||
newMail: t("New mail"),
|
||||
newMessage: t("New message"),
|
||||
|
||||
@@ -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,7 +4,7 @@
|
||||
* The rule this replaces was `subject.replace(/[^\w.-]+/g, "_")`, and `\w`
|
||||
* without the `u` flag is ASCII: every character of a Russian, Japanese or
|
||||
* Chinese subject failed the class, so those messages downloaded as a row of
|
||||
* underscores. ihasmail ships in nine languages besides English, so the
|
||||
* underscores. ihasmail ships in ten languages besides English, so the
|
||||
* subjects it handled worst were most of the world's.
|
||||
*
|
||||
* What is actually unsafe in a filename is a much shorter list than "not
|
||||
|
||||
@@ -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%; }
|
||||
|
||||
@@ -651,6 +651,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n} weitere",
|
||||
|
||||
"Contacts": "Kontakte",
|
||||
|
||||
"Collected": "Gesammelt",
|
||||
|
||||
"Save people you write to as contacts": "Personen, denen Sie schreiben, als Kontakte speichern",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Alle, an die Sie senden und die noch keine Kontakte sind, werden dem Adressbuch „Gesammelt“ hinzugefügt und auf jedem Gerät vorgeschlagen. Ihre eigenen Adressen werden nie hinzugefügt.",
|
||||
"Contacts are not available": "Kontakte sind nicht verfügbar",
|
||||
"New contact": "Neuer Kontakt",
|
||||
"Edit contact": "Kontakt bearbeiten",
|
||||
@@ -732,6 +738,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "Einstellungen",
|
||||
"Show folder list": "Ordnerliste einblenden",
|
||||
"Hide folder list": "Ordnerliste ausblenden",
|
||||
"All settings": "Alle Einstellungen",
|
||||
"Sections": "Bereiche",
|
||||
"General": "Allgemein",
|
||||
@@ -812,6 +820,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ß",
|
||||
@@ -1352,6 +1363,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Trotzdem senden",
|
||||
"Send canceled — the message is back in Drafts": "Senden abgebrochen – die Nachricht liegt wieder in den Entwürfen",
|
||||
"Send failed: {error}": "Senden fehlgeschlagen: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Es konnte nicht geprüft werden, ob diese Nachricht schon gesendet wurde. Prüfen Sie „Gesendet“, bevor Sie sie erneut senden.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Es konnte nicht bestätigt werden, ob diese Nachricht gesendet wurde. Prüfen Sie „Gesendet“, bevor Sie sie erneut senden.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Diese Nachricht war bereits gesendet und wurde nicht noch einmal gesendet.",
|
||||
"Send invites": "Einladungen senden",
|
||||
"Send scheduled for {when}": "Senden geplant für {when}",
|
||||
"Send without a subject?": "Ohne Betreff senden?",
|
||||
|
||||
@@ -643,6 +643,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n} más",
|
||||
|
||||
"Contacts": "Contactos",
|
||||
|
||||
"Collected": "Recopilados",
|
||||
|
||||
"Save people you write to as contacts": "Guardar como contactos a las personas a las que escribes",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Todas las personas a las que envías y que aún no son contactos se añaden a la libreta Recopilados, para que se sugieran en todos tus dispositivos. Tus propias direcciones nunca se añaden.",
|
||||
"Contacts are not available": "Los contactos no están disponibles",
|
||||
"New contact": "Contacto nuevo",
|
||||
"Edit contact": "Editar el contacto",
|
||||
@@ -727,6 +733,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "Configuración",
|
||||
"Show folder list": "Mostrar lista de carpetas",
|
||||
"Hide folder list": "Ocultar lista de carpetas",
|
||||
"All settings": "Toda la configuración",
|
||||
"Sections": "Secciones",
|
||||
"General": "General",
|
||||
@@ -808,6 +816,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",
|
||||
@@ -1325,6 +1336,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Enviar de todos modos",
|
||||
"Send canceled — the message is back in Drafts": "Envío cancelado: el mensaje ha vuelto a Borradores",
|
||||
"Send failed: {error}": "No se pudo enviar: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "No se pudo comprobar si este mensaje ya se envió. Revisa Enviados antes de volver a enviarlo.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "No se pudo confirmar si este mensaje se envió. Revisa Enviados antes de volver a enviarlo.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Este mensaje ya se había enviado, así que no se ha vuelto a enviar.",
|
||||
"Send invites": "Enviar invitaciones",
|
||||
"Send scheduled for {when}": "Envío programado para {when}",
|
||||
"Send without a subject?": "¿Enviar sin asunto?",
|
||||
|
||||
@@ -648,6 +648,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n} autres",
|
||||
|
||||
"Contacts": "Contacts",
|
||||
|
||||
"Collected": "Collectés",
|
||||
|
||||
"Save people you write to as contacts": "Enregistrer comme contacts les personnes à qui vous écrivez",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Toute personne à qui vous écrivez et qui n’est pas encore un contact est ajoutée au carnet Collectés, pour vous être proposée sur tous vos appareils. Vos propres adresses ne sont jamais ajoutées.",
|
||||
"Contacts are not available": "Les contacts ne sont pas disponibles",
|
||||
"New contact": "Nouveau contact",
|
||||
"Edit contact": "Modifier le contact",
|
||||
@@ -733,6 +739,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "Paramètres",
|
||||
"Show folder list": "Afficher la liste des dossiers",
|
||||
"Hide folder list": "Masquer la liste des dossiers",
|
||||
"All settings": "Tous les paramètres",
|
||||
"Sections": "Sections",
|
||||
"General": "Général",
|
||||
@@ -814,6 +822,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",
|
||||
@@ -1330,6 +1341,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Envoyer quand même",
|
||||
"Send canceled — the message is back in Drafts": "Envoi annulé : le message est de retour dans les brouillons",
|
||||
"Send failed: {error}": "Échec de l’envoi : {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Impossible de vérifier si ce message a déjà été envoyé. Consultez Envoyés avant de le renvoyer.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Impossible de confirmer l’envoi de ce message. Consultez Envoyés avant de le renvoyer.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Ce message avait déjà été envoyé, il n’a donc pas été renvoyé.",
|
||||
"Send invites": "Envoyer les invitations",
|
||||
"Send scheduled for {when}": "Envoi programmé pour {when}",
|
||||
"Send without a subject?": "Envoyer sans objet ?",
|
||||
|
||||
@@ -642,6 +642,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "他 {n} 件",
|
||||
|
||||
"Contacts": "連絡先",
|
||||
|
||||
"Collected": "収集したアドレス",
|
||||
|
||||
"Save people you write to as contacts": "メールを送った相手を連絡先に保存する",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "まだ連絡先にない送信先は「収集したアドレス」アドレス帳に追加され、どのデバイスでも候補に表示されます。自分のアドレスは追加されません。",
|
||||
"Contacts are not available": "連絡先は利用できません",
|
||||
"New contact": "新しい連絡先",
|
||||
"Edit contact": "連絡先を編集",
|
||||
@@ -727,6 +733,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "設定",
|
||||
"Show folder list": "フォルダー一覧を表示",
|
||||
"Hide folder list": "フォルダー一覧を隠す",
|
||||
"All settings": "すべての設定",
|
||||
"Sections": "セクション",
|
||||
"General": "一般",
|
||||
@@ -808,6 +816,9 @@ export const catalog: Catalog = {
|
||||
"Text size": "文字サイズ",
|
||||
"Font size": "フォントサイズ",
|
||||
"Small": "小",
|
||||
"Original size": "元のサイズ",
|
||||
"Drag to resize": "ドラッグしてサイズを変更",
|
||||
"Image size": "画像のサイズ",
|
||||
"Medium": "中",
|
||||
"Large": "大",
|
||||
"Huge": "特大",
|
||||
@@ -1333,6 +1344,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "このまま送信",
|
||||
"Send canceled — the message is back in Drafts": "送信を取り消しました。メールは下書きに戻っています",
|
||||
"Send failed: {error}": "送信できませんでした: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "このメッセージが送信済みかどうかを確認できませんでした。もう一度送信する前に送信済みフォルダーを確認してください。",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "このメッセージが送信されたかどうかを確認できませんでした。もう一度送信する前に送信済みフォルダーを確認してください。",
|
||||
"This message had already been sent, so it wasn't sent again.": "このメッセージはすでに送信されていたため、再送信しませんでした。",
|
||||
"Send invites": "招待を送信",
|
||||
"Send scheduled for {when}": "{when} に送信を予約しました",
|
||||
"Send without a subject?": "件名なしで送信しますか?",
|
||||
|
||||
@@ -643,6 +643,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n} meer",
|
||||
|
||||
"Contacts": "Contacten",
|
||||
|
||||
"Collected": "Verzameld",
|
||||
|
||||
"Save people you write to as contacts": "Mensen aan wie je schrijft als contact opslaan",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Iedereen aan wie je mailt en die nog geen contact is, wordt toegevoegd aan het adresboek Verzameld, zodat die op elk apparaat wordt voorgesteld. Je eigen adressen worden nooit toegevoegd.",
|
||||
"Contacts are not available": "Contacten zijn niet beschikbaar",
|
||||
"New contact": "Nieuw contact",
|
||||
"Edit contact": "Contact bewerken",
|
||||
@@ -728,6 +734,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "Instellingen",
|
||||
"Show folder list": "Mappenlijst tonen",
|
||||
"Hide folder list": "Mappenlijst verbergen",
|
||||
"All settings": "Alle instellingen",
|
||||
"Sections": "Onderdelen",
|
||||
"General": "Algemeen",
|
||||
@@ -809,6 +817,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",
|
||||
@@ -1325,6 +1336,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Toch verzenden",
|
||||
"Send canceled — the message is back in Drafts": "Verzenden geannuleerd — het bericht staat weer bij Concepten",
|
||||
"Send failed: {error}": "Verzenden mislukt: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Kon niet controleren of dit bericht al is verzonden. Kijk in Verzonden voordat je het opnieuw verstuurt.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Kon niet bevestigen of dit bericht is verzonden. Kijk in Verzonden voordat je het opnieuw verstuurt.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Dit bericht was al verzonden en is daarom niet opnieuw verstuurd.",
|
||||
"Send invites": "Uitnodigingen verzenden",
|
||||
"Send scheduled for {when}": "Verzenden gepland voor {when}",
|
||||
"Send without a subject?": "Verzenden zonder een onderwerp?",
|
||||
|
||||
@@ -0,0 +1,729 @@
|
||||
import type { PermissionCatalog } from "@/lib/permissionLabels";
|
||||
|
||||
/** Stalwart 0.16.22's permission labels in Turkish, keyed by permission name. Checked against source.json. */
|
||||
export const permissionCatalog: PermissionCatalog = {
|
||||
categories: {
|
||||
"AI models Management": "Yapay Zeka Modelleri Yönetimi",
|
||||
"API keys Management": "API Anahtarları Yönetimi",
|
||||
"Accounts Management": "Hesap Yönetimi",
|
||||
"Action": "Eylem",
|
||||
"Actions Management": "Eylem Yönetimi",
|
||||
"Addresses Management": "Adres Yönetimi",
|
||||
"Alerts Management": "Uyarı Yönetimi",
|
||||
"App passwords Management": "Uygulama Şifreleri Yönetimi",
|
||||
"Applications Management": "Uygulama Yönetimi",
|
||||
"Archived items Management": "Arşivlenmiş Ögeler Yönetimi",
|
||||
"Calendar": "Takvim",
|
||||
"Certificates Management": "Sertifika Yönetimi",
|
||||
"Cluster nodes Management": "Küme Düğümleri Yönetimi",
|
||||
"DNS servers Management": "DNS Sunucuları Yönetimi",
|
||||
"Directories Management": "Dizin Yönetimi",
|
||||
"Domains Management": "Alan Adı Yönetimi",
|
||||
"Email": "E-posta",
|
||||
"Events Management": "Etkinlik Yönetimi",
|
||||
"Extensions Management": "Uzantı Yönetimi",
|
||||
"Hooks Management": "Kanca (Hook) Yönetimi",
|
||||
"IMAP": "IMAP",
|
||||
"JMAP": "JMAP",
|
||||
"Keys Management": "Anahtar Yönetimi",
|
||||
"Listeners Management": "Dinleyici (Listener) Yönetimi",
|
||||
"Lists Management": "Liste Yönetimi",
|
||||
"Log entries Management": "Günlük Kayıtları Yönetimi",
|
||||
"Lookups Management": "Arama (Lookup) Yönetimi",
|
||||
"Mailing lists Management": "E-posta Listeleri Yönetimi",
|
||||
"ManageSieve": "ManageSieve",
|
||||
"Masked emails Management": "Maskelenmiş E-posta Yönetimi",
|
||||
"Messages Management": "İleti Yönetimi",
|
||||
"Metrics Management": "Metrik Yönetimi",
|
||||
"Milters Management": "Milter Yönetimi",
|
||||
"OAuth clients Management": "OAuth İstemcileri Yönetimi",
|
||||
"POP3": "POP3",
|
||||
"Providers Management": "Sağlayıcı Yönetimi",
|
||||
"Public keys Management": "Genel Anahtar Yönetimi",
|
||||
"Queues Management": "Kuyruk Yönetimi",
|
||||
"Quotas Management": "Kota Yönetimi",
|
||||
"Reports Management": "Rapor Yönetimi",
|
||||
"Roles Management": "Rol Yönetimi",
|
||||
"Routes Management": "Rota Yönetimi",
|
||||
"Rules Management": "Kural Yönetimi",
|
||||
"Samples Management": "Örnek Yönetimi",
|
||||
"Schedules Management": "Zamanlama Yönetimi",
|
||||
"Scripts Management": "Betik Yönetimi",
|
||||
"Servers Management": "Sunucu Yönetimi",
|
||||
"Settings": "Ayarlar",
|
||||
"Signatures Management": "İmza Yönetimi",
|
||||
"Strategies Management": "Strateji Yönetimi",
|
||||
"Tags Management": "Etiket Yönetimi",
|
||||
"Task": "Görev",
|
||||
"Tasks Management": "Görev Yönetimi",
|
||||
"Tenants Management": "Kiracı (Tenant) Yönetimi",
|
||||
"Throttles Management": "Hız Sınırlama (Throttle) Yönetimi",
|
||||
"Tracers Management": "İzleyici (Tracer) Yönetimi",
|
||||
"Traces Management": "İzleme Kayıtları (Trace) Yönetimi",
|
||||
"WebDAV": "WebDAV",
|
||||
"Webhooks Management": "Webhook Yönetimi",
|
||||
},
|
||||
labels: {
|
||||
authenticate: "Sunucuya giriş yap",
|
||||
authenticateWithAlias: "E-posta takma adları kullanarak giriş yap",
|
||||
interactAi: "Yapay zeka modelleriyle etkileşim kur",
|
||||
impersonate: "Başka bir kullanıcı adına işlem yap",
|
||||
unlimitedRequests: "İstek hız sınırlarını atla",
|
||||
unlimitedUploads: "Yükleme boyutu ve hız sınırlarını atla",
|
||||
fetchAnyBlob: "Hesaba ait olmayanlar dahil herhangi bir blob nesnesini al",
|
||||
emailSend: "E-postaları gönder",
|
||||
emailReceive: "E-postaları al",
|
||||
calendarAlarmsSend: "Takvim anımsatıcı bildirimlerini e-posta ile gönder",
|
||||
calendarSchedulingSend: "Takvim davetlerini ve güncellemelerini e-posta ile gönder",
|
||||
calendarSchedulingReceive: "Gelen takvim davetlerini ve yanıtlarını işle",
|
||||
jmapPushSubscriptionGet: "Push aboneliğini ayrıntılarını al",
|
||||
jmapPushSubscriptionCreate: "Yeni push aboneliklerini oluştur",
|
||||
jmapPushSubscriptionUpdate: "Mevcut push aboneliklerini düzenle",
|
||||
jmapPushSubscriptionDestroy: "Push aboneliklerini kaldır",
|
||||
jmapMailboxGet: "Posta kutusunu ayrıntılarını ve özelliklerini al",
|
||||
jmapMailboxChanges: "Belirli bir durumdan bu yana posta kutularını üzerindeki değişiklikleri izle",
|
||||
jmapMailboxQuery: "Kriterlerle eşleşen posta kutularını ara",
|
||||
jmapMailboxQueryChanges: "Posta kutusunu sorgu sonuçlarındaki değişiklikleri izle",
|
||||
jmapMailboxCreate: "Yeni posta kutularını oluştur",
|
||||
jmapMailboxUpdate: "Mevcut posta kutularını düzenle",
|
||||
jmapMailboxDestroy: "Posta kutularını kaldır",
|
||||
jmapThreadGet: "Email thread ayrıntılarını al",
|
||||
jmapThreadChanges: "Belirli bir durumdan bu yana yazışmaları üzerindeki değişiklikleri izle",
|
||||
jmapEmailGet: "E-postayı ayrıntılarını ve özelliklerini al",
|
||||
jmapEmailChanges: "Belirli bir durumdan bu yana e-postaları üzerindeki değişiklikleri izle",
|
||||
jmapEmailQuery: "Kriterlerle eşleşen e-postaları ara",
|
||||
jmapEmailQueryChanges: "E-postayı sorgu sonuçlarındaki değişiklikleri izle",
|
||||
jmapEmailCreate: "Yeni e-postaları oluştur",
|
||||
jmapEmailUpdate: "Mevcut e-postaları düzenle",
|
||||
jmapEmailDestroy: "E-postaları kaldır",
|
||||
jmapEmailCopy: "E-postaları başka bir hesaba kopyala",
|
||||
jmapEmailImport: "Blob verilerinden e-postaları içe aktar",
|
||||
jmapEmailParse: "Ham e-posta verilerini kaydetmeden ayrıştır",
|
||||
jmapSearchSnippetGet: "E-postalar için arama sonucu pasajlarını al",
|
||||
jmapIdentityGet: "Gönderen kimliğini ayrıntılarını al",
|
||||
jmapIdentityChanges: "Belirli bir durumdan bu yana kimlikleri üzerindeki değişiklikleri izle",
|
||||
jmapIdentityCreate: "Yeni gönderen kimliklerini oluştur",
|
||||
jmapIdentityUpdate: "Mevcut gönderen kimliklerini düzenle",
|
||||
jmapIdentityDestroy: "Gönderen kimliklerini kaldır",
|
||||
jmapEmailSubmissionGet: "E-posta gönderimini ayrıntılarını al",
|
||||
jmapEmailSubmissionChanges: "Belirli bir durumdan bu yana e-posta gönderimlerini üzerindeki değişiklikleri izle",
|
||||
jmapEmailSubmissionQuery: "Kriterlerle eşleşen e-posta gönderimlerini ara",
|
||||
jmapEmailSubmissionQueryChanges: "E-posta gönderimini sorgu sonuçlarındaki değişiklikleri izle",
|
||||
jmapEmailSubmissionCreate: "Yeni email submissions for sending oluştur",
|
||||
jmapEmailSubmissionUpdate: "Mevcut e-posta gönderimlerini düzenle",
|
||||
jmapEmailSubmissionDestroy: "E-posta gönderimlerini kaldır",
|
||||
jmapVacationResponseGet: "Tatil yanıtı yapılandırmasını al",
|
||||
jmapVacationResponseCreate: "Tatil yanıtı ayarlarını oluştur",
|
||||
jmapVacationResponseUpdate: "Tatil / otomatik yanıt ayarlarını düzenle",
|
||||
jmapVacationResponseDestroy: "Tatil yanıtı ayarlarını kaldır",
|
||||
jmapSieveScriptGet: "Sieve filtre betiğini ayrıntılarını al",
|
||||
jmapSieveScriptQuery: "Kriterlerle eşleşen Sieve betiklerini ara",
|
||||
jmapSieveScriptValidate: "Bir Sieve betiğini kaydetmeden doğrula",
|
||||
jmapSieveScriptCreate: "Yeni Sieve filtre betiklerini oluştur",
|
||||
jmapSieveScriptUpdate: "Mevcut Sieve filtre betiklerini düzenle",
|
||||
jmapSieveScriptDestroy: "Sieve filtre betiklerini kaldır",
|
||||
jmapPrincipalGet: "Kullanıcıyı/grubu (principal) ayrıntılarını ve özelliklerini al",
|
||||
jmapPrincipalQuery: "Kriterlerle eşleşen kullanıcıları/grupları (principal) ara",
|
||||
jmapPrincipalChanges: "Belirli bir durumdan bu yana kullanıcıları/grupları (principal) üzerindeki değişiklikleri izle",
|
||||
jmapPrincipalQueryChanges: "Kullanıcıyı/grubu (principal) sorgu sonuçlarındaki değişiklikleri izle",
|
||||
jmapPrincipalGetAvailability: "Availability information for principals sorgula",
|
||||
jmapPrincipalCreate: "Yeni kullanıcıları/grupları (principal) oluştur",
|
||||
jmapPrincipalUpdate: "Mevcut kullanıcıları/grupları (principal) düzenle",
|
||||
jmapPrincipalDestroy: "Kullanıcıları/grupları (principal) kaldır",
|
||||
jmapQuotaGet: "Kota kullanımını ve sınırlarını al",
|
||||
jmapQuotaChanges: "Belirli bir durumdan bu yana kotaları üzerindeki değişiklikleri izle",
|
||||
jmapQuotaQuery: "Kriterlerle eşleşen kotaları ara",
|
||||
jmapQuotaQueryChanges: "Kotayı sorgu sonuçlarındaki değişiklikleri izle",
|
||||
jmapBlobGet: "Blob data indir",
|
||||
jmapBlobCopy: "Blob nesnelerini başka bir hesaba kopyala",
|
||||
jmapBlobLookup: "Veri türleri genelinde blob kullanımını sorgula",
|
||||
jmapBlobUpload: "New blob data yükle",
|
||||
jmapAddressBookGet: "Adres defterini ayrıntılarını ve özelliklerini al",
|
||||
jmapAddressBookChanges: "Belirli bir durumdan bu yana adres defterlerini üzerindeki değişiklikleri izle",
|
||||
jmapAddressBookCreate: "Yeni adres defterlerini oluştur",
|
||||
jmapAddressBookUpdate: "Mevcut adres defterlerini düzenle",
|
||||
jmapAddressBookDestroy: "Adres defterlerini kaldır",
|
||||
jmapContactCardGet: "Kişi kartını ayrıntılarını al",
|
||||
jmapContactCardChanges: "Belirli bir durumdan bu yana kişi kartlarını üzerindeki değişiklikleri izle",
|
||||
jmapContactCardQuery: "Kriterlerle eşleşen kişi kartlarını ara",
|
||||
jmapContactCardQueryChanges: "Kişi kartını sorgu sonuçlarındaki değişiklikleri izle",
|
||||
jmapContactCardCreate: "Yeni kişi kartlarını oluştur",
|
||||
jmapContactCardUpdate: "Mevcut kişi kartlarını düzenle",
|
||||
jmapContactCardDestroy: "Kişi kartlarını kaldır",
|
||||
jmapContactCardCopy: "Kişi kartlarını başka bir hesaba kopyala",
|
||||
jmapContactCardParse: "Ham kişi kartı verilerini kaydetmeden ayrıştır",
|
||||
jmapFileNodeGet: "Dosya düğümünü ayrıntılarını ve özelliklerini al",
|
||||
jmapFileNodeChanges: "Belirli bir durumdan bu yana dosya düğümlerini üzerindeki değişiklikleri izle",
|
||||
jmapFileNodeQuery: "Kriterlerle eşleşen dosya düğümlerini ara",
|
||||
jmapFileNodeQueryChanges: "Dosya düğümünü sorgu sonuçlarındaki değişiklikleri izle",
|
||||
jmapFileNodeCreate: "Yeni dosya düğümlerini oluştur",
|
||||
jmapFileNodeUpdate: "Mevcut dosya düğümlerini düzenle",
|
||||
jmapFileNodeDestroy: "Dosya düğümlerini kaldır",
|
||||
jmapFileNodeCopy: "Dosya düğümlerini başka bir hesaba kopyala",
|
||||
jmapShareNotificationGet: "Paylaşım bildirimini ayrıntılarını al",
|
||||
jmapShareNotificationChanges: "Belirli bir durumdan bu yana paylaşım bildirimlerini üzerindeki değişiklikleri izle",
|
||||
jmapShareNotificationQuery: "Kriterlerle eşleşen paylaşım bildirimlerini ara",
|
||||
jmapShareNotificationQueryChanges: "Paylaşım bildirimini sorgu sonuçlarındaki değişiklikleri izle",
|
||||
jmapShareNotificationCreate: "Yeni paylaşım bildirimlerini oluştur",
|
||||
jmapShareNotificationUpdate: "Mevcut paylaşım bildirimlerini düzenle",
|
||||
jmapShareNotificationDestroy: "Paylaşım bildirimlerini kaldır",
|
||||
jmapCalendarGet: "Takvimi ayrıntılarını ve özelliklerini al",
|
||||
jmapCalendarChanges: "Belirli bir durumdan bu yana takvimleri üzerindeki değişiklikleri izle",
|
||||
jmapCalendarCreate: "Yeni takvimleri oluştur",
|
||||
jmapCalendarUpdate: "Mevcut takvimleri düzenle",
|
||||
jmapCalendarDestroy: "Takvimleri kaldır",
|
||||
jmapCalendarEventGet: "Takvim etkinliğini ayrıntılarını al",
|
||||
jmapCalendarEventChanges: "Belirli bir durumdan bu yana takvim etkinliklerini üzerindeki değişiklikleri izle",
|
||||
jmapCalendarEventQuery: "Kriterlerle eşleşen takvim etkinliklerini ara",
|
||||
jmapCalendarEventQueryChanges: "Takvim etkinliğini sorgu sonuçlarındaki değişiklikleri izle",
|
||||
jmapCalendarEventCreate: "Yeni takvim etkinliklerini oluştur",
|
||||
jmapCalendarEventUpdate: "Mevcut takvim etkinliklerini düzenle",
|
||||
jmapCalendarEventDestroy: "Takvim etkinliklerini kaldır",
|
||||
jmapCalendarEventCopy: "Takvim etkinliklerini başka bir hesaba kopyala",
|
||||
jmapCalendarEventParse: "Ham takvim etkinliği verilerini kaydetmeden ayrıştır",
|
||||
jmapCalendarEventNotificationGet: "Takvim etkinliği bildirimini ayrıntılarını al",
|
||||
jmapCalendarEventNotificationChanges: "Belirli bir durumdan bu yana takvim etkinliği bildirimlerini üzerindeki değişiklikleri izle",
|
||||
jmapCalendarEventNotificationQuery: "Kriterlerle eşleşen takvim etkinliği bildirimlerini ara",
|
||||
jmapCalendarEventNotificationQueryChanges: "Takvim etkinliği bildirimini sorgu sonuçlarındaki değişiklikleri izle",
|
||||
jmapCalendarEventNotificationCreate: "Yeni takvim etkinliği bildirimlerini oluştur",
|
||||
jmapCalendarEventNotificationUpdate: "Mevcut takvim etkinliği bildirimlerini düzenle",
|
||||
jmapCalendarEventNotificationDestroy: "Takvim etkinliği bildirimlerini kaldır",
|
||||
jmapParticipantIdentityGet: "Participant identity ayrıntılarını al",
|
||||
jmapParticipantIdentityChanges: "Belirli bir durumdan bu yana participant identities üzerindeki değişiklikleri izle",
|
||||
jmapParticipantIdentityCreate: "Yeni participant identities oluştur",
|
||||
jmapParticipantIdentityUpdate: "Mevcut participant identities düzenle",
|
||||
jmapParticipantIdentityDestroy: "Participant identities kaldır",
|
||||
jmapCoreEcho: "Test amacıyla giriş verisini geri yankıla (echo)",
|
||||
imapAuthenticate: "IMAP sunucusuna giriş yap",
|
||||
imapAclGet: "Posta kutusu erişim kontrol listelerini (ACL) görüntüle",
|
||||
imapAclSet: "Posta kutusu erişim kontrol listelerini (ACL) düzenle",
|
||||
imapMyRights: "Bir posta kutusundaki kendi yetkilerini görüntüle",
|
||||
imapListRights: "Bir posta kutusu için kullanılabilir yetki bayraklarını listele",
|
||||
imapAppend: "Messages to a mailbox yükle",
|
||||
imapCapability: "Supported imap extensions sorgula",
|
||||
imapId: "Server identification and version sorgula",
|
||||
imapCopy: "Posta kutuları arasında iletileri kopyala",
|
||||
imapMove: "Posta kutuları arasında iletileri taşı",
|
||||
imapCreate: "Yeni posta kutularını oluştur",
|
||||
imapDelete: "Mailboxes or mark messages for deletion sil",
|
||||
imapEnable: "İsteğe bağlı IMAP uzantılarını etkinleştir",
|
||||
imapExpunge: "Silinmek üzere işaretlenmiş iletileri kalıcı olarak kaldır (expunge)",
|
||||
imapFetch: "Message content and metadata indir",
|
||||
imapIdle: "Posta kutusu değişiklikleri için gerçek zamanlı bildirimler al (idle)",
|
||||
imapList: "Kullanılabilir posta kutularını ve özniteliklerini listele",
|
||||
imapLsub: "Kullanıcının abone olduğu posta kutularını listele",
|
||||
imapNamespace: "Available mailbox namespace prefixes sorgula",
|
||||
imapRename: "Posta kutularını yeniden adlandır veya taşı",
|
||||
imapSearch: "Kriterlere göre iletileri ara",
|
||||
imapSort: "İletileri belirli bir sıralama düzeninde al",
|
||||
imapSelect: "Bir posta kutusunu okuma-yazma erişimi için aç",
|
||||
imapExamine: "Bir posta kutusunu salt okunur erişim için aç",
|
||||
imapStatus: "Mailbox message counts and state sorgula",
|
||||
imapStore: "İleti bayraklarını ayarla veya temizle (okundu, yıldızlı, silindi vb.)",
|
||||
imapSubscribe: "Posta kutularına abone ol veya abonelikten çık",
|
||||
imapThread: "İletileri yazışma dizileri halinde grupla",
|
||||
pop3Authenticate: "POP3 sunucusuna giriş yap",
|
||||
pop3List: "İletileri ve boyutlarını listele",
|
||||
pop3Uidl: "Benzersiz ileti tanımlayıcılarını al (UIDL)",
|
||||
pop3Stat: "Mailbox message count and total size sorgula",
|
||||
pop3Retr: "Message content indir",
|
||||
pop3Dele: "Bağlantı kesildiğinde silinmek üzere iletileri işaretle",
|
||||
sieveAuthenticate: "ManageSieve sunucusuna giriş yap",
|
||||
sieveListScripts: "Yüklenen Sieve betiklerini listele",
|
||||
sieveSetActive: "Hangi Sieve betiğinin aktif olduğunu belirle",
|
||||
sieveGetScript: "Sieve script content indir",
|
||||
sievePutScript: "Or replace a sieve script yükle",
|
||||
sieveDeleteScript: "A sieve script kaldır",
|
||||
sieveRenameScript: "Bir Sieve betiğini yeniden adlandır",
|
||||
sieveCheckScript: "Sieve betiği sözdizimini kaydetmeden doğrula",
|
||||
sieveHaveSpace: "Kotanın belirli boyuttaki bir betiği yüklemeye izin verip vermediğini denetle",
|
||||
davSyncCollection: "Koleksiyon değişikliklerini istemciyle eşitle",
|
||||
davExpandProperty: "Diğer kaynaklara başvuran özellikleri genişlet",
|
||||
davPrincipalAcl: "Kullanıcılar/gruplar (principal) üzerindeki erişim kontrol özelliklerini yönet",
|
||||
davPrincipalList: "Sistemdeki kullanılabilir kullanıcıları/grupları (principal) listele",
|
||||
davPrincipalMatch: "Belirtilen kriterlere göre kullanıcıları/grupları (principal) eşleştir",
|
||||
davPrincipalSearch: "Özellik değerlerine göre kullanıcıları/grupları (principal) ara",
|
||||
davPrincipalSearchPropSet: "Which properties can be searched on principals sorgula",
|
||||
davFilePropFind: "Dosya kaynaklarının özelliklerini al",
|
||||
davFilePropPatch: "Dosya kaynaklarının özelliklerini düzenle",
|
||||
davFileGet: "File resources indir",
|
||||
davFileMkCol: "Yeni file collections or directories oluştur",
|
||||
davFileDelete: "File resources kaldır",
|
||||
davFilePut: "Or modify file resources yükle",
|
||||
davFileCopy: "Dosya kaynaklarını yeni konumlara kopyala",
|
||||
davFileMove: "Dosya kaynaklarını yeni konumlara taşı",
|
||||
davFileLock: "Eşzamanlı değişiklikleri önlemek için dosya kaynaklarını kilitle",
|
||||
davFileAcl: "Dosya kaynakları için erişim kontrol listelerini (ACL) yönet",
|
||||
davCardPropFind: "Adres defteri kayıtlarının özelliklerini al",
|
||||
davCardPropPatch: "Adres defteri kayıtlarının özelliklerini düzenle",
|
||||
davCardGet: "Address book entries indir",
|
||||
davCardMkCol: "Yeni address book collections oluştur",
|
||||
davCardDelete: "Address book entries or collections kaldır",
|
||||
davCardPut: "Or modify address book entries yükle",
|
||||
davCardCopy: "Adres defteri kayıtlarını yeni konumlara kopyala",
|
||||
davCardMove: "Adres defteri kayıtlarını yeni konumlara taşı",
|
||||
davCardLock: "Eşzamanlı değişiklikleri önlemek için adres defteri kayıtlarını kilitle",
|
||||
davCardAcl: "Adres defteri kayıtları için erişim kontrol listelerini (ACL) yönet",
|
||||
davCardQuery: "Kriterlerle eşleşen address book entries ara",
|
||||
davCardMultiGet: "Tek bir istekte birden çok adres defteri kaydı al",
|
||||
davCalPropFind: "Takvim kayıtlarının özelliklerini al",
|
||||
davCalPropPatch: "Takvim kayıtlarının özelliklerini düzenle",
|
||||
davCalGet: "Calendar entries indir",
|
||||
davCalMkCol: "Yeni calendar collections oluştur",
|
||||
davCalDelete: "Calendar entries or collections kaldır",
|
||||
davCalPut: "Or modify calendar entries yükle",
|
||||
davCalCopy: "Takvim kayıtlarını yeni konumlara kopyala",
|
||||
davCalMove: "Takvim kayıtlarını yeni konumlara taşı",
|
||||
davCalLock: "Eşzamanlı değişiklikleri önlemek için takvim kayıtlarını kilitle",
|
||||
davCalAcl: "Takvim kayıtları için erişim kontrol listelerini (ACL) yönet",
|
||||
davCalQuery: "Kriterlerle eşleşen calendar entries ara",
|
||||
davCalMultiGet: "Tek bir istekte birden çok takvim kaydı al",
|
||||
davCalFreeBusyQuery: "Free/busy time information for scheduling sorgula",
|
||||
oAuthClientRegistration: "Yeni OAuth istemci uygulamaları kaydet",
|
||||
oAuthClientOverride: "OAuth istemci kayıt gereksinimlerini atla",
|
||||
liveTracing: "Gerçek zamanlı sunucu etkinlik izlerini (trace) görüntüle",
|
||||
liveMetrics: "Gerçek zamanlı sunucu performans metriklerini görüntüle",
|
||||
liveDeliveryTest: "Teslimat yapılandırmasını doğrulamak için test e-postaları gönder",
|
||||
scimAccess: "SCIM uç noktası üzerinden kullanıcı ve grup sağla (provision)",
|
||||
sysAccountGet: "Hesapları getir",
|
||||
sysAccountCreate: "Hesapları oluştur",
|
||||
sysAccountUpdate: "Hesapları güncelle",
|
||||
sysAccountDestroy: "Hesapları sil",
|
||||
sysAccountQuery: "Hesapları sorgula",
|
||||
sysAccountPasswordGet: "Account password settings getir",
|
||||
sysAccountPasswordUpdate: "Account password settings güncelle",
|
||||
sysAccountSettingsGet: "Account settings settings getir",
|
||||
sysAccountSettingsUpdate: "Account settings settings güncelle",
|
||||
sysAcmeProviderGet: "Sağlayıcıları getir",
|
||||
sysAcmeProviderCreate: "Sağlayıcıları oluştur",
|
||||
sysAcmeProviderUpdate: "Sağlayıcıları güncelle",
|
||||
sysAcmeProviderDestroy: "Sağlayıcıları sil",
|
||||
sysAcmeProviderQuery: "Sağlayıcıları sorgula",
|
||||
actionReloadSettings: "Yeniden yükle: Sunucu ayarları",
|
||||
actionReloadTlsCertificates: "Yeniden yükle: TLS sertifikaları",
|
||||
actionReloadLookupStores: "Yeniden yükle: Arama (lookup) depoları",
|
||||
actionReloadBlockedIps: "Yeniden yükle: Engellenen IP listesi",
|
||||
actionUpdateApps: "Uygulama Yönetimi: Uygulamaları güncelle",
|
||||
actionTroubleshootDmarc: "DMARC: Sorun giderme",
|
||||
actionClassifySpam: "Spam Filtresi: Bir iletiyi sınıflandır",
|
||||
actionInvalidateCaches: "Önbellek: Tüm önbellekleri geçersiz kıl",
|
||||
actionInvalidateNegativeCaches: "Önbellek: Negatif önbellekleri geçersiz kıl",
|
||||
actionPauseMtaQueue: "MTA: Kuyruk işlemeyi duraklat",
|
||||
actionResumeMtaQueue: "MTA: Kuyruk işlemeyi sürdür",
|
||||
sysActionGet: "Eylemleri getir",
|
||||
sysActionCreate: "Eylemleri oluştur",
|
||||
sysActionUpdate: "Eylemleri güncelle",
|
||||
sysActionDestroy: "Eylemleri sil",
|
||||
sysActionQuery: "Eylemleri sorgula",
|
||||
sysAddressBookGet: "Address book settings getir",
|
||||
sysAddressBookUpdate: "Address book settings güncelle",
|
||||
sysAiModelGet: "Yapay zeka modellerini getir",
|
||||
sysAiModelCreate: "Yapay zeka modellerini oluştur",
|
||||
sysAiModelUpdate: "Yapay zeka modellerini güncelle",
|
||||
sysAiModelDestroy: "Yapay zeka modellerini sil",
|
||||
sysAiModelQuery: "Yapay zeka modellerini sorgula",
|
||||
sysAlertGet: "Uyarıları getir",
|
||||
sysAlertCreate: "Uyarıları oluştur",
|
||||
sysAlertUpdate: "Uyarıları güncelle",
|
||||
sysAlertDestroy: "Uyarıları sil",
|
||||
sysAlertQuery: "Uyarıları sorgula",
|
||||
sysAllowedIpGet: "Adresleri getir",
|
||||
sysAllowedIpCreate: "Adresleri oluştur",
|
||||
sysAllowedIpUpdate: "Adresleri güncelle",
|
||||
sysAllowedIpDestroy: "Adresleri sil",
|
||||
sysAllowedIpQuery: "Adresleri sorgula",
|
||||
sysApiKeyGet: "Api anahtarlarını getir",
|
||||
sysApiKeyCreate: "Api anahtarlarını oluştur",
|
||||
sysApiKeyUpdate: "Api anahtarlarını güncelle",
|
||||
sysApiKeyDestroy: "Api anahtarlarını sil",
|
||||
sysApiKeyQuery: "Api anahtarlarını sorgula",
|
||||
sysAppPasswordGet: "Uygulama şifrelerini getir",
|
||||
sysAppPasswordCreate: "Uygulama şifrelerini oluştur",
|
||||
sysAppPasswordUpdate: "Uygulama şifrelerini güncelle",
|
||||
sysAppPasswordDestroy: "Uygulama şifrelerini sil",
|
||||
sysAppPasswordQuery: "Uygulama şifrelerini sorgula",
|
||||
sysApplicationGet: "Uygulamaları getir",
|
||||
sysApplicationCreate: "Uygulamaları oluştur",
|
||||
sysApplicationUpdate: "Uygulamaları güncelle",
|
||||
sysApplicationDestroy: "Uygulamaları sil",
|
||||
sysApplicationQuery: "Uygulamaları sorgula",
|
||||
sysArchivedItemGet: "Arşivlenmiş ögeleri getir",
|
||||
sysArchivedItemCreate: "Arşivlenmiş ögeleri oluştur",
|
||||
sysArchivedItemUpdate: "Arşivlenmiş ögeleri güncelle",
|
||||
sysArchivedItemDestroy: "Arşivlenmiş ögeleri sil",
|
||||
sysArchivedItemQuery: "Arşivlenmiş ögeleri sorgula",
|
||||
sysArfExternalReportGet: "Raporları getir",
|
||||
sysArfExternalReportCreate: "Raporları oluştur",
|
||||
sysArfExternalReportUpdate: "Raporları güncelle",
|
||||
sysArfExternalReportDestroy: "Raporları sil",
|
||||
sysArfExternalReportQuery: "Raporları sorgula",
|
||||
sysAsnGet: "Asn settings getir",
|
||||
sysAsnUpdate: "Asn settings güncelle",
|
||||
sysAuthenticationGet: "Authentication settings getir",
|
||||
sysAuthenticationUpdate: "Authentication settings güncelle",
|
||||
sysBlobStoreGet: "Blob store settings getir",
|
||||
sysBlobStoreUpdate: "Blob store settings güncelle",
|
||||
sysBlockedIpGet: "Adresleri getir",
|
||||
sysBlockedIpCreate: "Adresleri oluştur",
|
||||
sysBlockedIpUpdate: "Adresleri güncelle",
|
||||
sysBlockedIpDestroy: "Adresleri sil",
|
||||
sysBlockedIpQuery: "Adresleri sorgula",
|
||||
sysBootstrapGet: "Bootstrap settings getir",
|
||||
sysBootstrapUpdate: "Bootstrap settings güncelle",
|
||||
sysCacheGet: "Cache settings getir",
|
||||
sysCacheUpdate: "Cache settings güncelle",
|
||||
sysCalendarGet: "Calendar settings getir",
|
||||
sysCalendarUpdate: "Calendar settings güncelle",
|
||||
sysCalendarAlarmGet: "Calendar alarm settings getir",
|
||||
sysCalendarAlarmUpdate: "Calendar alarm settings güncelle",
|
||||
sysCalendarSchedulingGet: "Calendar scheduling settings getir",
|
||||
sysCalendarSchedulingUpdate: "Calendar scheduling settings güncelle",
|
||||
sysCertificateGet: "Sertifikaları getir",
|
||||
sysCertificateCreate: "Sertifikaları oluştur",
|
||||
sysCertificateUpdate: "Sertifikaları güncelle",
|
||||
sysCertificateDestroy: "Sertifikaları sil",
|
||||
sysCertificateQuery: "Sertifikaları sorgula",
|
||||
sysClusterNodeGet: "Küme düğümlerini getir",
|
||||
sysClusterNodeCreate: "Küme düğümlerini oluştur",
|
||||
sysClusterNodeUpdate: "Küme düğümlerini güncelle",
|
||||
sysClusterNodeDestroy: "Küme düğümlerini sil",
|
||||
sysClusterNodeQuery: "Küme düğümlerini sorgula",
|
||||
sysClusterRoleGet: "Rolleri getir",
|
||||
sysClusterRoleCreate: "Rolleri oluştur",
|
||||
sysClusterRoleUpdate: "Rolleri güncelle",
|
||||
sysClusterRoleDestroy: "Rolleri sil",
|
||||
sysClusterRoleQuery: "Rolleri sorgula",
|
||||
sysCoordinatorGet: "Coordinator settings getir",
|
||||
sysCoordinatorUpdate: "Coordinator settings güncelle",
|
||||
sysDataRetentionGet: "Data retention settings getir",
|
||||
sysDataRetentionUpdate: "Data retention settings güncelle",
|
||||
sysDataStoreGet: "Data store settings getir",
|
||||
sysDataStoreUpdate: "Data store settings güncelle",
|
||||
sysDirectoryGet: "Dizinleri getir",
|
||||
sysDirectoryCreate: "Dizinleri oluştur",
|
||||
sysDirectoryUpdate: "Dizinleri güncelle",
|
||||
sysDirectoryDestroy: "Dizinleri sil",
|
||||
sysDirectoryQuery: "Dizinleri sorgula",
|
||||
sysDkimReportSettingsGet: "Dkim report settings settings getir",
|
||||
sysDkimReportSettingsUpdate: "Dkim report settings settings güncelle",
|
||||
sysDkimSignatureGet: "Imzaları getir",
|
||||
sysDkimSignatureCreate: "Imzaları oluştur",
|
||||
sysDkimSignatureUpdate: "Imzaları güncelle",
|
||||
sysDkimSignatureDestroy: "Imzaları sil",
|
||||
sysDkimSignatureQuery: "Imzaları sorgula",
|
||||
sysDmarcExternalReportGet: "Raporları getir",
|
||||
sysDmarcExternalReportCreate: "Raporları oluştur",
|
||||
sysDmarcExternalReportUpdate: "Raporları güncelle",
|
||||
sysDmarcExternalReportDestroy: "Raporları sil",
|
||||
sysDmarcExternalReportQuery: "Raporları sorgula",
|
||||
sysDmarcInternalReportGet: "Raporları getir",
|
||||
sysDmarcInternalReportCreate: "Raporları oluştur",
|
||||
sysDmarcInternalReportUpdate: "Raporları güncelle",
|
||||
sysDmarcInternalReportDestroy: "Raporları sil",
|
||||
sysDmarcInternalReportQuery: "Raporları sorgula",
|
||||
sysDmarcReportSettingsGet: "Dmarc report settings settings getir",
|
||||
sysDmarcReportSettingsUpdate: "Dmarc report settings settings güncelle",
|
||||
sysDnsResolverGet: "Dns resolver settings getir",
|
||||
sysDnsResolverUpdate: "Dns resolver settings güncelle",
|
||||
sysDnsServerGet: "Dns sunucularını getir",
|
||||
sysDnsServerCreate: "Dns sunucularını oluştur",
|
||||
sysDnsServerUpdate: "Dns sunucularını güncelle",
|
||||
sysDnsServerDestroy: "Dns sunucularını sil",
|
||||
sysDnsServerQuery: "Dns sunucularını sorgula",
|
||||
sysDomainGet: "Alan adlarını getir",
|
||||
sysDomainCreate: "Alan adlarını oluştur",
|
||||
sysDomainUpdate: "Alan adlarını güncelle",
|
||||
sysDomainDestroy: "Alan adlarını sil",
|
||||
sysDomainQuery: "Alan adlarını sorgula",
|
||||
sysDsnReportSettingsGet: "Dsn report settings settings getir",
|
||||
sysDsnReportSettingsUpdate: "Dsn report settings settings güncelle",
|
||||
sysEmailGet: "Email settings getir",
|
||||
sysEmailUpdate: "Email settings güncelle",
|
||||
sysEnterpriseGet: "Enterprise settings getir",
|
||||
sysEnterpriseUpdate: "Enterprise settings güncelle",
|
||||
sysEventTracingLevelGet: "Etkinlikleri getir",
|
||||
sysEventTracingLevelCreate: "Etkinlikleri oluştur",
|
||||
sysEventTracingLevelUpdate: "Etkinlikleri güncelle",
|
||||
sysEventTracingLevelDestroy: "Etkinlikleri sil",
|
||||
sysEventTracingLevelQuery: "Etkinlikleri sorgula",
|
||||
sysFileStorageGet: "File storage settings getir",
|
||||
sysFileStorageUpdate: "File storage settings güncelle",
|
||||
sysHttpGet: "Http settings getir",
|
||||
sysHttpUpdate: "Http settings güncelle",
|
||||
sysHttpFormGet: "Http form settings getir",
|
||||
sysHttpFormUpdate: "Http form settings güncelle",
|
||||
sysHttpLookupGet: "Listeleri getir",
|
||||
sysHttpLookupCreate: "Listeleri oluştur",
|
||||
sysHttpLookupUpdate: "Listeleri güncelle",
|
||||
sysHttpLookupDestroy: "Listeleri sil",
|
||||
sysHttpLookupQuery: "Listeleri sorgula",
|
||||
sysImapGet: "Imap settings getir",
|
||||
sysImapUpdate: "Imap settings güncelle",
|
||||
sysInMemoryStoreGet: "In memory store settings getir",
|
||||
sysInMemoryStoreUpdate: "In memory store settings güncelle",
|
||||
sysJmapGet: "Jmap settings getir",
|
||||
sysJmapUpdate: "Jmap settings güncelle",
|
||||
sysLogGet: "Günlük kayıtlarını getir",
|
||||
sysLogCreate: "Günlük kayıtlarını oluştur",
|
||||
sysLogUpdate: "Günlük kayıtlarını güncelle",
|
||||
sysLogDestroy: "Günlük kayıtlarını sil",
|
||||
sysLogQuery: "Günlük kayıtlarını sorgula",
|
||||
sysMailingListGet: "E-posta listelerini getir",
|
||||
sysMailingListCreate: "E-posta listelerini oluştur",
|
||||
sysMailingListUpdate: "E-posta listelerini güncelle",
|
||||
sysMailingListDestroy: "E-posta listelerini sil",
|
||||
sysMailingListQuery: "E-posta listelerini sorgula",
|
||||
sysMaskedEmailGet: "Maskelenmiş e-postaları getir",
|
||||
sysMaskedEmailCreate: "Maskelenmiş e-postaları oluştur",
|
||||
sysMaskedEmailUpdate: "Maskelenmiş e-postaları güncelle",
|
||||
sysMaskedEmailDestroy: "Maskelenmiş e-postaları sil",
|
||||
sysMaskedEmailQuery: "Maskelenmiş e-postaları sorgula",
|
||||
sysMemoryLookupKeyGet: "Anahtarları getir",
|
||||
sysMemoryLookupKeyCreate: "Anahtarları oluştur",
|
||||
sysMemoryLookupKeyUpdate: "Anahtarları güncelle",
|
||||
sysMemoryLookupKeyDestroy: "Anahtarları sil",
|
||||
sysMemoryLookupKeyQuery: "Anahtarları sorgula",
|
||||
sysMemoryLookupKeyValueGet: "Anahtarları getir",
|
||||
sysMemoryLookupKeyValueCreate: "Anahtarları oluştur",
|
||||
sysMemoryLookupKeyValueUpdate: "Anahtarları güncelle",
|
||||
sysMemoryLookupKeyValueDestroy: "Anahtarları sil",
|
||||
sysMemoryLookupKeyValueQuery: "Anahtarları sorgula",
|
||||
sysMetricGet: "Metrikleri getir",
|
||||
sysMetricCreate: "Metrikleri oluştur",
|
||||
sysMetricUpdate: "Metrikleri güncelle",
|
||||
sysMetricDestroy: "Metrikleri sil",
|
||||
sysMetricQuery: "Metrikleri sorgula",
|
||||
sysMetricsGet: "Metrics settings getir",
|
||||
sysMetricsUpdate: "Metrics settings güncelle",
|
||||
sysMetricsStoreGet: "Metrics store settings getir",
|
||||
sysMetricsStoreUpdate: "Metrics store settings güncelle",
|
||||
sysMtaConnectionStrategyGet: "Stratejileri getir",
|
||||
sysMtaConnectionStrategyCreate: "Stratejileri oluştur",
|
||||
sysMtaConnectionStrategyUpdate: "Stratejileri güncelle",
|
||||
sysMtaConnectionStrategyDestroy: "Stratejileri sil",
|
||||
sysMtaConnectionStrategyQuery: "Stratejileri sorgula",
|
||||
sysMtaDeliveryScheduleGet: "Zamanlamaları getir",
|
||||
sysMtaDeliveryScheduleCreate: "Zamanlamaları oluştur",
|
||||
sysMtaDeliveryScheduleUpdate: "Zamanlamaları güncelle",
|
||||
sysMtaDeliveryScheduleDestroy: "Zamanlamaları sil",
|
||||
sysMtaDeliveryScheduleQuery: "Zamanlamaları sorgula",
|
||||
sysMtaExtensionsGet: "Mta extensions settings getir",
|
||||
sysMtaExtensionsUpdate: "Mta extensions settings güncelle",
|
||||
sysMtaHookGet: "Kancaları (hook) getir",
|
||||
sysMtaHookCreate: "Kancaları (hook) oluştur",
|
||||
sysMtaHookUpdate: "Kancaları (hook) güncelle",
|
||||
sysMtaHookDestroy: "Kancaları (hook) sil",
|
||||
sysMtaHookQuery: "Kancaları (hook) sorgula",
|
||||
sysMtaInboundSessionGet: "Mta inbound session settings getir",
|
||||
sysMtaInboundSessionUpdate: "Mta inbound session settings güncelle",
|
||||
sysMtaInboundThrottleGet: "Hız sınırlamalarını (throttle) getir",
|
||||
sysMtaInboundThrottleCreate: "Hız sınırlamalarını (throttle) oluştur",
|
||||
sysMtaInboundThrottleUpdate: "Hız sınırlamalarını (throttle) güncelle",
|
||||
sysMtaInboundThrottleDestroy: "Hız sınırlamalarını (throttle) sil",
|
||||
sysMtaInboundThrottleQuery: "Hız sınırlamalarını (throttle) sorgula",
|
||||
sysMtaMilterGet: "Milter'ları getir",
|
||||
sysMtaMilterCreate: "Milter'ları oluştur",
|
||||
sysMtaMilterUpdate: "Milter'ları güncelle",
|
||||
sysMtaMilterDestroy: "Milter'ları sil",
|
||||
sysMtaMilterQuery: "Milter'ları sorgula",
|
||||
sysMtaOutboundStrategyGet: "Mta outbound strategy settings getir",
|
||||
sysMtaOutboundStrategyUpdate: "Mta outbound strategy settings güncelle",
|
||||
sysMtaOutboundThrottleGet: "Hız sınırlamalarını (throttle) getir",
|
||||
sysMtaOutboundThrottleCreate: "Hız sınırlamalarını (throttle) oluştur",
|
||||
sysMtaOutboundThrottleUpdate: "Hız sınırlamalarını (throttle) güncelle",
|
||||
sysMtaOutboundThrottleDestroy: "Hız sınırlamalarını (throttle) sil",
|
||||
sysMtaOutboundThrottleQuery: "Hız sınırlamalarını (throttle) sorgula",
|
||||
sysMtaQueueQuotaGet: "Kotaları getir",
|
||||
sysMtaQueueQuotaCreate: "Kotaları oluştur",
|
||||
sysMtaQueueQuotaUpdate: "Kotaları güncelle",
|
||||
sysMtaQueueQuotaDestroy: "Kotaları sil",
|
||||
sysMtaQueueQuotaQuery: "Kotaları sorgula",
|
||||
sysMtaRouteGet: "Rotaları getir",
|
||||
sysMtaRouteCreate: "Rotaları oluştur",
|
||||
sysMtaRouteUpdate: "Rotaları güncelle",
|
||||
sysMtaRouteDestroy: "Rotaları sil",
|
||||
sysMtaRouteQuery: "Rotaları sorgula",
|
||||
sysMtaStageAuthGet: "Mta stage auth settings getir",
|
||||
sysMtaStageAuthUpdate: "Mta stage auth settings güncelle",
|
||||
sysMtaStageConnectGet: "Mta stage connect settings getir",
|
||||
sysMtaStageConnectUpdate: "Mta stage connect settings güncelle",
|
||||
sysMtaStageDataGet: "Mta stage data settings getir",
|
||||
sysMtaStageDataUpdate: "Mta stage data settings güncelle",
|
||||
sysMtaStageEhloGet: "Mta stage ehlo settings getir",
|
||||
sysMtaStageEhloUpdate: "Mta stage ehlo settings güncelle",
|
||||
sysMtaStageMailGet: "Mta stage mail settings getir",
|
||||
sysMtaStageMailUpdate: "Mta stage mail settings güncelle",
|
||||
sysMtaStageRcptGet: "Mta stage rcpt settings getir",
|
||||
sysMtaStageRcptUpdate: "Mta stage rcpt settings güncelle",
|
||||
sysMtaStsGet: "Mta sts settings getir",
|
||||
sysMtaStsUpdate: "Mta sts settings güncelle",
|
||||
sysMtaTlsStrategyGet: "Stratejileri getir",
|
||||
sysMtaTlsStrategyCreate: "Stratejileri oluştur",
|
||||
sysMtaTlsStrategyUpdate: "Stratejileri güncelle",
|
||||
sysMtaTlsStrategyDestroy: "Stratejileri sil",
|
||||
sysMtaTlsStrategyQuery: "Stratejileri sorgula",
|
||||
sysMtaVirtualQueueGet: "Kuyrukları getir",
|
||||
sysMtaVirtualQueueCreate: "Kuyrukları oluştur",
|
||||
sysMtaVirtualQueueUpdate: "Kuyrukları güncelle",
|
||||
sysMtaVirtualQueueDestroy: "Kuyrukları sil",
|
||||
sysMtaVirtualQueueQuery: "Kuyrukları sorgula",
|
||||
sysNetworkListenerGet: "Dinleyicileri (listener) getir",
|
||||
sysNetworkListenerCreate: "Dinleyicileri (listener) oluştur",
|
||||
sysNetworkListenerUpdate: "Dinleyicileri (listener) güncelle",
|
||||
sysNetworkListenerDestroy: "Dinleyicileri (listener) sil",
|
||||
sysNetworkListenerQuery: "Dinleyicileri (listener) sorgula",
|
||||
sysOAuthClientGet: "Oauth istemcilerini getir",
|
||||
sysOAuthClientCreate: "Oauth istemcilerini oluştur",
|
||||
sysOAuthClientUpdate: "Oauth istemcilerini güncelle",
|
||||
sysOAuthClientDestroy: "Oauth istemcilerini sil",
|
||||
sysOAuthClientQuery: "Oauth istemcilerini sorgula",
|
||||
sysOidcProviderGet: "Oidc provider settings getir",
|
||||
sysOidcProviderUpdate: "Oidc provider settings güncelle",
|
||||
sysPublicKeyGet: "Genel anahtarları getir",
|
||||
sysPublicKeyCreate: "Genel anahtarları oluştur",
|
||||
sysPublicKeyUpdate: "Genel anahtarları güncelle",
|
||||
sysPublicKeyDestroy: "Genel anahtarları sil",
|
||||
sysPublicKeyQuery: "Genel anahtarları sorgula",
|
||||
sysQueuedMessageGet: "Iletileri getir",
|
||||
sysQueuedMessageCreate: "Iletileri oluştur",
|
||||
sysQueuedMessageUpdate: "Iletileri güncelle",
|
||||
sysQueuedMessageDestroy: "Iletileri sil",
|
||||
sysQueuedMessageQuery: "Iletileri sorgula",
|
||||
sysReportSettingsGet: "Report settings settings getir",
|
||||
sysReportSettingsUpdate: "Report settings settings güncelle",
|
||||
sysRoleGet: "Rolleri getir",
|
||||
sysRoleCreate: "Rolleri oluştur",
|
||||
sysRoleUpdate: "Rolleri güncelle",
|
||||
sysRoleDestroy: "Rolleri sil",
|
||||
sysRoleQuery: "Rolleri sorgula",
|
||||
sysSearchGet: "Search settings getir",
|
||||
sysSearchUpdate: "Search settings güncelle",
|
||||
sysSearchStoreGet: "Search store settings getir",
|
||||
sysSearchStoreUpdate: "Search store settings güncelle",
|
||||
sysSecurityGet: "Security settings getir",
|
||||
sysSecurityUpdate: "Security settings güncelle",
|
||||
sysSenderAuthGet: "Sender auth settings getir",
|
||||
sysSenderAuthUpdate: "Sender auth settings güncelle",
|
||||
sysSharingGet: "Sharing settings getir",
|
||||
sysSharingUpdate: "Sharing settings güncelle",
|
||||
sysSieveSystemInterpreterGet: "Sieve system interpreter settings getir",
|
||||
sysSieveSystemInterpreterUpdate: "Sieve system interpreter settings güncelle",
|
||||
sysSieveSystemScriptGet: "Betikleri getir",
|
||||
sysSieveSystemScriptCreate: "Betikleri oluştur",
|
||||
sysSieveSystemScriptUpdate: "Betikleri güncelle",
|
||||
sysSieveSystemScriptDestroy: "Betikleri sil",
|
||||
sysSieveSystemScriptQuery: "Betikleri sorgula",
|
||||
sysSieveUserInterpreterGet: "Sieve user interpreter settings getir",
|
||||
sysSieveUserInterpreterUpdate: "Sieve user interpreter settings güncelle",
|
||||
sysSieveUserScriptGet: "Betikleri getir",
|
||||
sysSieveUserScriptCreate: "Betikleri oluştur",
|
||||
sysSieveUserScriptUpdate: "Betikleri güncelle",
|
||||
sysSieveUserScriptDestroy: "Betikleri sil",
|
||||
sysSieveUserScriptQuery: "Betikleri sorgula",
|
||||
sysSpamClassifierGet: "Spam classifier settings getir",
|
||||
sysSpamClassifierUpdate: "Spam classifier settings güncelle",
|
||||
sysSpamDnsblServerGet: "Sunucuları getir",
|
||||
sysSpamDnsblServerCreate: "Sunucuları oluştur",
|
||||
sysSpamDnsblServerUpdate: "Sunucuları güncelle",
|
||||
sysSpamDnsblServerDestroy: "Sunucuları sil",
|
||||
sysSpamDnsblServerQuery: "Sunucuları sorgula",
|
||||
sysSpamDnsblSettingsGet: "Spam dnsbl settings settings getir",
|
||||
sysSpamDnsblSettingsUpdate: "Spam dnsbl settings settings güncelle",
|
||||
sysSpamFileExtensionGet: "Uzantıları getir",
|
||||
sysSpamFileExtensionCreate: "Uzantıları oluştur",
|
||||
sysSpamFileExtensionUpdate: "Uzantıları güncelle",
|
||||
sysSpamFileExtensionDestroy: "Uzantıları sil",
|
||||
sysSpamFileExtensionQuery: "Uzantıları sorgula",
|
||||
sysSpamLlmGet: "Spam llm settings getir",
|
||||
sysSpamLlmUpdate: "Spam llm settings güncelle",
|
||||
sysSpamPyzorGet: "Spam pyzor settings getir",
|
||||
sysSpamPyzorUpdate: "Spam pyzor settings güncelle",
|
||||
sysSpamRuleGet: "Kuralları getir",
|
||||
sysSpamRuleCreate: "Kuralları oluştur",
|
||||
sysSpamRuleUpdate: "Kuralları güncelle",
|
||||
sysSpamRuleDestroy: "Kuralları sil",
|
||||
sysSpamRuleQuery: "Kuralları sorgula",
|
||||
sysSpamSettingsGet: "Spam settings settings getir",
|
||||
sysSpamSettingsUpdate: "Spam settings settings güncelle",
|
||||
sysSpamTagGet: "Etiketleri getir",
|
||||
sysSpamTagCreate: "Etiketleri oluştur",
|
||||
sysSpamTagUpdate: "Etiketleri güncelle",
|
||||
sysSpamTagDestroy: "Etiketleri sil",
|
||||
sysSpamTagQuery: "Etiketleri sorgula",
|
||||
sysSpamTrainingSampleGet: "Örnekleri getir",
|
||||
sysSpamTrainingSampleCreate: "Örnekleri oluştur",
|
||||
sysSpamTrainingSampleUpdate: "Örnekleri güncelle",
|
||||
sysSpamTrainingSampleDestroy: "Örnekleri sil",
|
||||
sysSpamTrainingSampleQuery: "Örnekleri sorgula",
|
||||
sysSpfReportSettingsGet: "Spf report settings settings getir",
|
||||
sysSpfReportSettingsUpdate: "Spf report settings settings güncelle",
|
||||
sysStoreLookupGet: "Aramaları (lookup) getir",
|
||||
sysStoreLookupCreate: "Aramaları (lookup) oluştur",
|
||||
sysStoreLookupUpdate: "Aramaları (lookup) güncelle",
|
||||
sysStoreLookupDestroy: "Aramaları (lookup) sil",
|
||||
sysStoreLookupQuery: "Aramaları (lookup) sorgula",
|
||||
sysSystemSettingsGet: "System settings settings getir",
|
||||
sysSystemSettingsUpdate: "System settings settings güncelle",
|
||||
taskIndexDocument: "Belgeyi dizine ekle",
|
||||
taskUnindexDocument: "Belgeyi dizinden çıkar",
|
||||
taskIndexTrace: "Telemetri izini (trace) dizine ekle",
|
||||
taskCalendarAlarmEmail: "Takvim anımsatıcı e-postası",
|
||||
taskCalendarAlarmNotification: "Takvim anımsatıcı bildirimi",
|
||||
taskCalendarItipMessage: "Takvim iTIP iletisi",
|
||||
taskMergeThreads: "E-posta yazışma dizilerini birleştir",
|
||||
taskDmarcReport: "Uzak sunucuya DMARC raporu gönder",
|
||||
taskTlsReport: "Uzak sunucuya TLS raporu gönder",
|
||||
taskRestoreArchivedItem: "Arşivlenmiş ögeyi geri yükle",
|
||||
taskDestroyAccount: "Account and all associated data sil",
|
||||
taskAccountMaintenance: "account maintenance operations işlemini gerçekleştir",
|
||||
taskTenantMaintenance: "tenant maintenance operations işlemini gerçekleştir",
|
||||
taskStoreMaintenance: "store maintenance operations işlemini gerçekleştir",
|
||||
taskSpamFilterMaintenance: "spam filter maintenance operations işlemini gerçekleştir",
|
||||
taskAcmeRenewal: "ACME certificate renewal for a domain işlemini gerçekleştir",
|
||||
taskDkimManagement: "DKIM key rotation for a domain işlemini gerçekleştir",
|
||||
taskDnsManagement: "DNS management for a domain işlemini gerçekleştir",
|
||||
sysTaskGet: "Görevleri getir",
|
||||
sysTaskCreate: "Görevleri oluştur",
|
||||
sysTaskUpdate: "Görevleri güncelle",
|
||||
sysTaskDestroy: "Görevleri sil",
|
||||
sysTaskQuery: "Görevleri sorgula",
|
||||
sysTaskManagerGet: "Task manager settings getir",
|
||||
sysTaskManagerUpdate: "Task manager settings güncelle",
|
||||
sysTenantGet: "Kiracıları (tenant) getir",
|
||||
sysTenantCreate: "Kiracıları (tenant) oluştur",
|
||||
sysTenantUpdate: "Kiracıları (tenant) güncelle",
|
||||
sysTenantDestroy: "Kiracıları (tenant) sil",
|
||||
sysTenantQuery: "Kiracıları (tenant) sorgula",
|
||||
sysTlsExternalReportGet: "Raporları getir",
|
||||
sysTlsExternalReportCreate: "Raporları oluştur",
|
||||
sysTlsExternalReportUpdate: "Raporları güncelle",
|
||||
sysTlsExternalReportDestroy: "Raporları sil",
|
||||
sysTlsExternalReportQuery: "Raporları sorgula",
|
||||
sysTlsInternalReportGet: "Raporları getir",
|
||||
sysTlsInternalReportCreate: "Raporları oluştur",
|
||||
sysTlsInternalReportUpdate: "Raporları güncelle",
|
||||
sysTlsInternalReportDestroy: "Raporları sil",
|
||||
sysTlsInternalReportQuery: "Raporları sorgula",
|
||||
sysTlsReportSettingsGet: "Tls report settings settings getir",
|
||||
sysTlsReportSettingsUpdate: "Tls report settings settings güncelle",
|
||||
sysTraceGet: "Izleme kayıtlarını (trace) getir",
|
||||
sysTraceCreate: "Izleme kayıtlarını (trace) oluştur",
|
||||
sysTraceUpdate: "Izleme kayıtlarını (trace) güncelle",
|
||||
sysTraceDestroy: "Izleme kayıtlarını (trace) sil",
|
||||
sysTraceQuery: "Izleme kayıtlarını (trace) sorgula",
|
||||
sysTracerGet: "Izleyicileri (tracer) getir",
|
||||
sysTracerCreate: "Izleyicileri (tracer) oluştur",
|
||||
sysTracerUpdate: "Izleyicileri (tracer) güncelle",
|
||||
sysTracerDestroy: "Izleyicileri (tracer) sil",
|
||||
sysTracerQuery: "Izleyicileri (tracer) sorgula",
|
||||
sysTracingStoreGet: "Tracing store settings getir",
|
||||
sysTracingStoreUpdate: "Tracing store settings güncelle",
|
||||
sysWebDavGet: "Web dav settings getir",
|
||||
sysWebDavUpdate: "Web dav settings güncelle",
|
||||
sysWebHookGet: "Webhook'ları getir",
|
||||
sysWebHookCreate: "Webhook'ları oluştur",
|
||||
sysWebHookUpdate: "Webhook'ları güncelle",
|
||||
sysWebHookDestroy: "Webhook'ları sil",
|
||||
sysWebHookQuery: "Webhook'ları sorgula",
|
||||
},
|
||||
};
|
||||
@@ -646,6 +646,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n} outros",
|
||||
|
||||
"Contacts": "Contatos",
|
||||
|
||||
"Collected": "Coletados",
|
||||
|
||||
"Save people you write to as contacts": "Salvar como contatos as pessoas para quem você escreve",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Todos para quem você envia e que ainda não são contatos são adicionados à agenda Coletados, para serem sugeridos em todos os dispositivos. Seus próprios endereços nunca são adicionados.",
|
||||
"Contacts are not available": "Os contatos não estão disponíveis",
|
||||
"New contact": "Novo contato",
|
||||
"Edit contact": "Editar o contato",
|
||||
@@ -730,6 +736,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "Configurações",
|
||||
"Show folder list": "Mostrar lista de pastas",
|
||||
"Hide folder list": "Ocultar lista de pastas",
|
||||
"All settings": "Todas as configurações",
|
||||
"Sections": "Seções",
|
||||
"General": "Geral",
|
||||
@@ -811,6 +819,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",
|
||||
@@ -1328,6 +1339,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Enviar mesmo assim",
|
||||
"Send canceled — the message is back in Drafts": "Envio cancelado — a mensagem voltou para Rascunhos",
|
||||
"Send failed: {error}": "Falha ao enviar: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Não foi possível verificar se esta mensagem já foi enviada. Confira Enviados antes de enviá-la novamente.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Não foi possível confirmar se esta mensagem foi enviada. Confira Enviados antes de enviá-la novamente.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Esta mensagem já tinha sido enviada, então não foi enviada de novo.",
|
||||
"Send invites": "Enviar convites",
|
||||
"Send scheduled for {when}": "Envio agendado para {when}",
|
||||
"Send without a subject?": "Enviar sem assunto?",
|
||||
|
||||
@@ -645,6 +645,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n}",
|
||||
|
||||
"Contacts": "Контакты",
|
||||
|
||||
"Collected": "Собранные",
|
||||
|
||||
"Save people you write to as contacts": "Сохранять адресатов как контакты",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Все, кому вы пишете и кого ещё нет в контактах, добавляются в адресную книгу «Собранные» и предлагаются на всех устройствах. Ваши собственные адреса не добавляются.",
|
||||
"Contacts are not available": "Контакты недоступны",
|
||||
"New contact": "Новый контакт",
|
||||
"Edit contact": "Изменить контакт",
|
||||
@@ -730,6 +736,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "Настройки",
|
||||
"Show folder list": "Показать список папок",
|
||||
"Hide folder list": "Скрыть список папок",
|
||||
"All settings": "Все настройки",
|
||||
"Sections": "Разделы",
|
||||
"General": "Общие",
|
||||
@@ -811,6 +819,9 @@ export const catalog: Catalog = {
|
||||
"Text size": "Размер текста",
|
||||
"Font size": "Размер шрифта",
|
||||
"Small": "Мелкий",
|
||||
"Original size": "Исходный размер",
|
||||
"Drag to resize": "Перетащите, чтобы изменить размер",
|
||||
"Image size": "Размер изображения",
|
||||
"Medium": "Средний",
|
||||
"Large": "Крупный",
|
||||
"Huge": "Очень крупный",
|
||||
@@ -1327,6 +1338,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Всё равно отправить",
|
||||
"Send canceled — the message is back in Drafts": "Отправка отменена — письмо вернулось в черновики",
|
||||
"Send failed: {error}": "Не удалось отправить: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Не удалось проверить, было ли это письмо уже отправлено. Проверьте «Отправленные», прежде чем отправлять его снова.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Не удалось подтвердить, было ли это письмо отправлено. Проверьте «Отправленные», прежде чем отправлять его снова.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Это письмо уже было отправлено, поэтому повторно оно не отправлялось.",
|
||||
"Send invites": "Отправить приглашения",
|
||||
"Send scheduled for {when}": "Отправка запланирована на {when}",
|
||||
"Send without a subject?": "Отправить без темы?",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -639,6 +639,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "+{n}",
|
||||
|
||||
"Contacts": "Контакти",
|
||||
|
||||
"Collected": "Зібрані",
|
||||
|
||||
"Save people you write to as contacts": "Зберігати адресатів як контакти",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "Усі, кому ви пишете і кого ще немає в контактах, додаються до адресної книги «Зібрані» й пропонуються на всіх пристроях. Ваші власні адреси не додаються.",
|
||||
"Contacts are not available": "Контакти недоступні",
|
||||
"New contact": "Новий контакт",
|
||||
"Edit contact": "Змінити контакт",
|
||||
@@ -724,6 +730,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "Налаштування",
|
||||
"Show folder list": "Показати список тек",
|
||||
"Hide folder list": "Сховати список тек",
|
||||
"All settings": "Усі налаштування",
|
||||
"Sections": "Розділи",
|
||||
"General": "Загальні",
|
||||
@@ -805,6 +813,9 @@ export const catalog: Catalog = {
|
||||
"Text size": "Розмір тексту",
|
||||
"Font size": "Розмір шрифту",
|
||||
"Small": "Дрібний",
|
||||
"Original size": "Початковий розмір",
|
||||
"Drag to resize": "Перетягніть, щоб змінити розмір",
|
||||
"Image size": "Розмір зображення",
|
||||
"Medium": "Середній",
|
||||
"Large": "Великий",
|
||||
"Huge": "Дуже великий",
|
||||
@@ -1321,6 +1332,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "Усе одно надіслати",
|
||||
"Send canceled — the message is back in Drafts": "Надсилання скасовано — лист повернувся до чернеток",
|
||||
"Send failed: {error}": "Не вдалося надіслати: {error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "Не вдалося перевірити, чи цей лист уже надіслано. Перевірте «Надіслані», перш ніж надсилати його знову.",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "Не вдалося підтвердити, чи цей лист надіслано. Перевірте «Надіслані», перш ніж надсилати його знову.",
|
||||
"This message had already been sent, so it wasn't sent again.": "Цей лист уже було надіслано, тому повторно його не надсилали.",
|
||||
"Send invites": "Надіслати запрошення",
|
||||
"Send scheduled for {when}": "Надсилання заплановано на {when}",
|
||||
"Send without a subject?": "Надіслати без теми?",
|
||||
|
||||
@@ -641,6 +641,12 @@ export const catalog: Catalog = {
|
||||
"+{n} more": "还有 {n} 项",
|
||||
|
||||
"Contacts": "联系人",
|
||||
|
||||
"Collected": "已收集",
|
||||
|
||||
"Save people you write to as contacts": "将您发信的联系人保存为联系人",
|
||||
|
||||
"Everyone you send to who isn’t a contact yet is added to the Collected address book, so they’re suggested on every device. Your own addresses are never added.": "您发信的对象如果还不是联系人,会被添加到“已收集”通讯录,在所有设备上都会作为建议显示。您自己的地址不会被添加。",
|
||||
"Contacts are not available": "联系人不可用",
|
||||
"New contact": "新建联系人",
|
||||
"Edit contact": "编辑联系人",
|
||||
@@ -726,6 +732,8 @@ export const catalog: Catalog = {
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────
|
||||
"Settings": "设置",
|
||||
"Show folder list": "显示文件夹列表",
|
||||
"Hide folder list": "隐藏文件夹列表",
|
||||
"All settings": "全部设置",
|
||||
"Sections": "分区",
|
||||
"General": "常规",
|
||||
@@ -807,6 +815,9 @@ export const catalog: Catalog = {
|
||||
"Text size": "文字大小",
|
||||
"Font size": "字号",
|
||||
"Small": "小",
|
||||
"Original size": "原始大小",
|
||||
"Drag to resize": "拖动以调整大小",
|
||||
"Image size": "图片大小",
|
||||
"Medium": "中",
|
||||
"Large": "大",
|
||||
"Huge": "特大",
|
||||
@@ -1332,6 +1343,9 @@ export const catalog: Catalog = {
|
||||
"Send anyway": "仍然发送",
|
||||
"Send canceled — the message is back in Drafts": "已取消发送——邮件已回到草稿箱",
|
||||
"Send failed: {error}": "发送失败:{error}",
|
||||
"Couldn't check whether this message was already sent. Check Sent before sending it again.": "无法检查这封邮件是否已发送。请先查看“已发送”,再决定是否重新发送。",
|
||||
"Couldn't confirm whether this message was sent. Check Sent before sending it again.": "无法确认这封邮件是否已发送。请先查看“已发送”,再决定是否重新发送。",
|
||||
"This message had already been sent, so it wasn't sent again.": "这封邮件已经发送过,因此没有再次发送。",
|
||||
"Send invites": "发送邀请",
|
||||
"Send scheduled for {when}": "已定时于 {when} 发送",
|
||||
"Send without a subject?": "不填主题就发送吗?",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSession } from "@/store/session";
|
||||
|
||||
/**
|
||||
* inbuxa MA-B: the accounts signed in in this browser. The server lists them
|
||||
* and swaps the one in front; the web app clears what it cached for the
|
||||
* previous account and reloads, and signing out of one reloads into the next.
|
||||
*/
|
||||
|
||||
let reload: ReturnType<typeof vi.fn>;
|
||||
let calls: { url: string; method: string }[];
|
||||
let answers: Record<string, unknown>;
|
||||
|
||||
beforeEach(() => {
|
||||
reload = vi.fn();
|
||||
Object.defineProperty(window, "location", { configurable: true, value: { ...window.location, reload } });
|
||||
calls = [];
|
||||
answers = {};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const path = String(url);
|
||||
calls.push({ url: path, method: init?.method ?? "GET" });
|
||||
const key = Object.keys(answers).find((k) => path.endsWith(k));
|
||||
const body = key ? answers[key] : { ok: true };
|
||||
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as Response;
|
||||
}),
|
||||
);
|
||||
localStorage.setItem("ihasmail:cached-thing", "from the account in front");
|
||||
useSession.setState({ status: "authenticated", signedIn: [], canAddAccount: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("account switcher", () => {
|
||||
it("lists the accounts and whether one more may be added", async () => {
|
||||
answers["/api/auth/accounts"] = {
|
||||
accounts: [
|
||||
{ id: "a", username: "[email protected]", front: true },
|
||||
{ id: "b", username: "[email protected]", front: false },
|
||||
],
|
||||
canAdd: true,
|
||||
};
|
||||
await useSession.getState().loadSignedIn();
|
||||
expect(useSession.getState().signedIn.map((a) => a.username)).toEqual(["[email protected]", "[email protected]"]);
|
||||
expect(useSession.getState().canAddAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("switching asks the server, forgets the cache and reloads", async () => {
|
||||
await useSession.getState().switchTo("b");
|
||||
expect(calls.some((c) => c.url.endsWith("/api/auth/accounts/b/front") && c.method === "POST")).toBe(true);
|
||||
expect(localStorage.getItem("ihasmail:cached-thing")).toBeNull();
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("signing out of one reloads into the next when there is one", async () => {
|
||||
answers["/api/auth/logout"] = { ok: true, next: true };
|
||||
await useSession.getState().logout();
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("signing out of the last one, or of all, ends at the sign-in page", async () => {
|
||||
answers["/api/auth/logout-all"] = { ok: true };
|
||||
await useSession.getState().logoutAll();
|
||||
expect(calls.some((c) => c.url.endsWith("/api/auth/logout-all"))).toBe(true);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(useSession.getState().status).toBe("anonymous");
|
||||
// The next ordinary sign-out goes back to signing out of one
|
||||
useSession.setState({ status: "authenticated" });
|
||||
answers["/api/auth/logout"] = { ok: true };
|
||||
await useSession.getState().logout();
|
||||
expect(calls.filter((c) => c.url.endsWith("/api/auth/logout")).length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
|
||||
/* A confirmed send hands its recipients to the contacts store, unless the reader turned that off. */
|
||||
|
||||
function okServer() {
|
||||
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||
if (name === "Email/set" && args.create) return [name, { accountId: "a1", oldState: "1", newState: "2", created: { m: { id: "e1" } } }, id];
|
||||
if (name === "EmailSubmission/set") return [name, { accountId: "a1", oldState: "1", newState: "2", created: { s: { id: "s1" } } }, id];
|
||||
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
|
||||
});
|
||||
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
||||
}));
|
||||
}
|
||||
|
||||
let collect: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
client.session = {
|
||||
capabilities: { [CAP.core]: {}, [CAP.mail]: {}, [CAP.submission]: {} },
|
||||
accounts: { a1: { accountCapabilities: { [CAP.mail]: {}, [CAP.submission]: {} } } },
|
||||
primaryAccounts: {},
|
||||
state: "s1",
|
||||
} as unknown as JmapSession;
|
||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, undoSendSeconds: 0 } });
|
||||
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
|
||||
useMail.setState({
|
||||
accountId: "a1",
|
||||
identities: [{ id: "i1", name: "Me", email: "[email protected]", replyTo: null }] as never,
|
||||
mailboxes: { mbSent: { id: "mbSent", role: "sent", parentId: null, name: "Sent" } } as never,
|
||||
});
|
||||
collect = vi.fn(async () => 1);
|
||||
useContacts.setState({ collectRecipients: collect as never });
|
||||
okServer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const send = async () => {
|
||||
const key = useCompose.getState().open({ to: [{ name: "Ann", email: "[email protected]" }], cc: [{ name: null, email: "[email protected]" }], subject: "Hi" });
|
||||
await useCompose.getState().send(key);
|
||||
};
|
||||
|
||||
describe("collecting recipients when a message is sent", () => {
|
||||
it("passes every recipient, and the sender's own addresses to leave out", async () => {
|
||||
await send();
|
||||
expect(collect).toHaveBeenCalledTimes(1);
|
||||
const [addrs, own] = collect.mock.calls[0]!;
|
||||
expect((addrs as { email: string }[]).map((a) => a.email)).toEqual(["[email protected]", "[email protected]"]);
|
||||
expect(own).toEqual(["[email protected]"]);
|
||||
});
|
||||
|
||||
it("does not when the setting is off", async () => {
|
||||
useSettings.setState((s) => ({ settings: { ...s.settings, collectRecipients: false } }));
|
||||
await send();
|
||||
expect(collect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
|
||||
import type { ContactCard, JmapSession } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
* People written to used to be remembered only in this browser, as a list of
|
||||
* recent addresses, so a new device suggested nobody. Each confirmed send now
|
||||
* saves the recipients who are not contacts yet, in an address book of their
|
||||
* own, on the server.
|
||||
*/
|
||||
|
||||
interface Calls {
|
||||
books: Record<string, unknown>[];
|
||||
cards: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
function server(): Calls {
|
||||
const calls: Calls = { books: [], cards: [] };
|
||||
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||
if (name === "AddressBook/set" && args.create) {
|
||||
calls.books.push(args.create as Record<string, unknown>);
|
||||
useContacts.setState((s) => ({ books: { ...s.books, bNew: { id: "bNew", name: "Collected" } as never } }));
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", created: { b: { id: "bNew" } } }, id];
|
||||
}
|
||||
if (name === "ContactCard/set" && args.create) {
|
||||
const create = args.create as Record<string, unknown>;
|
||||
calls.cards.push(...Object.values(create) as Record<string, unknown>[]);
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", created: Object.fromEntries(Object.keys(create).map((k, i) => [k, { id: `n${i}` }])) }, id];
|
||||
}
|
||||
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false, changed: [], created: [], updated: [], destroyed: [], hasMoreChanges: false, oldState: "1", newState: "1" }, id];
|
||||
});
|
||||
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
||||
}));
|
||||
return calls;
|
||||
}
|
||||
|
||||
const card = (id: string, email: string): ContactCard =>
|
||||
({ id, uid: id, addressBookIds: { b1: true }, name: { full: id }, emails: { e: { address: email } } }) as unknown as ContactCard;
|
||||
|
||||
beforeEach(() => {
|
||||
client.session = {
|
||||
capabilities: { [CAP.core]: { maxObjectsInSet: 500 }, [CAP.contacts]: {} },
|
||||
accounts: { a1: { accountCapabilities: { [CAP.contacts]: {} } } },
|
||||
primaryAccounts: { [CAP.contacts]: "a1" },
|
||||
state: "s1",
|
||||
} as unknown as JmapSession;
|
||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS } });
|
||||
useContacts.setState({
|
||||
accountId: "a1",
|
||||
available: true,
|
||||
loaded: true,
|
||||
books: { b1: { id: "b1", name: "Personal" } as never },
|
||||
cards: { c1: card("c1", "[email protected]") },
|
||||
sharedCards: {},
|
||||
syncCards: (async () => undefined) as never,
|
||||
loadBooks: (async () => undefined) as never,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("saving the people written to", () => {
|
||||
it("adds only addresses that are not contacts yet, and never your own", async () => {
|
||||
const calls = server();
|
||||
const n = await useContacts.getState().collectRecipients(
|
||||
[
|
||||
{ name: "Known Person", email: "[email protected]" },
|
||||
{ name: "Smith, Jane", email: "[email protected]" },
|
||||
{ name: null, email: "[email protected]" },
|
||||
{ name: "Jane again", email: "[email protected]" },
|
||||
],
|
||||
["[email protected]"],
|
||||
);
|
||||
expect(n).toBe(1);
|
||||
expect(calls.cards).toHaveLength(1);
|
||||
const added = calls.cards[0]!;
|
||||
expect(Object.values(added.emails as Record<string, { address: string }>)[0]!.address).toBe("[email protected]");
|
||||
// Filed under the Collected book, with the name split the way the editor does.
|
||||
expect(added.addressBookIds).toEqual({ bNew: true });
|
||||
expect((added.name as { components: { kind: string; value: string }[] }).components.map((c) => c.value)).toEqual(expect.arrayContaining(["Jane", "Smith"]));
|
||||
});
|
||||
|
||||
it("creates the Collected book once and remembers it", async () => {
|
||||
const calls = server();
|
||||
await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||
expect(calls.books).toHaveLength(1);
|
||||
expect(useSettings.getState().settings.collectedBookId).toBe("bNew");
|
||||
await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||
expect(calls.books).toHaveLength(1);
|
||||
expect(calls.cards.at(-1)!.addressBookIds).toEqual({ bNew: true });
|
||||
});
|
||||
|
||||
it("makes a new book when the remembered one has been deleted", async () => {
|
||||
const calls = server();
|
||||
useSettings.setState((s) => ({ settings: { ...s.settings, collectedBookId: "gone" } }));
|
||||
await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||
expect(calls.books).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does nothing, and makes no book, when everyone is already a contact", async () => {
|
||||
const calls = server();
|
||||
const n = await useContacts.getState().collectRecipients([{ name: null, email: "[email protected]" }], []);
|
||||
expect(n).toBe(0);
|
||||
expect(calls.books).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
@@ -70,7 +70,7 @@ afterEach(() => {
|
||||
describe("delegation", () => {
|
||||
it("is read only from the session's mark, never from a shared account's capabilities", () => {
|
||||
const session = sessionWith(delegated("organize", true));
|
||||
expect(delegationOf(session, "locked")).toEqual({ locked: true, access: "organize", sendAs: true, until: null });
|
||||
expect(delegationOf(session, "locked")).toEqual({ locked: true, kind: "lock", access: "organize", sendAs: true, until: null });
|
||||
expect(delegationOf(session, "shared")).toBeNull();
|
||||
expect(delegationOf(session, "own")).toBeNull();
|
||||
expect(delegatedAccounts(session).map((a) => a.id)).toEqual(["locked"]);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import type { ContactCard } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
* What the recipient field offers as you type. Matching used to take the
|
||||
* typed text as one piece, so "jane smi" found nobody filed as "Smith, Jane",
|
||||
* and a nickname or a company name found nothing at all.
|
||||
*/
|
||||
|
||||
const card = (id: string, full: string, email: string, extra: Partial<ContactCard> = {}): ContactCard =>
|
||||
({ id, uid: id, addressBookIds: { b1: true }, name: { full }, emails: { e: { address: email } }, ...extra }) as unknown as ContactCard;
|
||||
|
||||
beforeEach(() => {
|
||||
useContacts.setState({
|
||||
accountId: "a1",
|
||||
available: false,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
principalsLoaded: true,
|
||||
principals: [],
|
||||
sharedCards: {},
|
||||
recent: [],
|
||||
cards: {
|
||||
c1: card("c1", "Smith, Jane", "[email protected]"),
|
||||
c2: card("c2", "Robert Jones", "[email protected]", { nicknames: { n: { name: "Bobby" } } } as Partial<ContactCard>),
|
||||
c3: card("c3", "Ann Lee", "[email protected]", { organizations: { o: { name: "Globex Industries" } } } as Partial<ContactCard>),
|
||||
c4: card("c4", "Ann Taylor", "[email protected]"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const emails = async (q: string) => (await useContacts.getState().suggest(q)).map((s) => s.email);
|
||||
|
||||
describe("recipient suggestions", () => {
|
||||
it("matches the words typed in any order", async () => {
|
||||
expect(await emails("jane smi")).toEqual(["[email protected]"]);
|
||||
expect(await emails("smi jan")).toEqual(["[email protected]"]);
|
||||
});
|
||||
|
||||
it("does not match when one of the words fits nobody", async () => {
|
||||
expect(await emails("jane xyz")).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches a nickname and an organization", async () => {
|
||||
expect(await emails("bobby")).toEqual(["[email protected]"]);
|
||||
expect(await emails("globex")).toEqual(["[email protected]"]);
|
||||
});
|
||||
|
||||
it("puts someone written to lately ahead of an equal match", async () => {
|
||||
expect(await emails("ann")).toEqual(["[email protected]", "[email protected]"]);
|
||||
useContacts.setState({ recent: [{ name: "Ann Taylor", email: "[email protected]" }] });
|
||||
expect((await emails("ann"))[0]).toBe("[email protected]");
|
||||
});
|
||||
|
||||
it("still ranks a match at the start above a word inside the name", async () => {
|
||||
// "jo" starts Jones's surname word; it starts no other card's name or address.
|
||||
expect(await emails("jo")).toEqual(["[email protected]"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError, CAP, client } from "@/jmap/client";
|
||||
import { sendOutcomeUnknown, useCompose } from "@/store/compose";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
|
||||
import { useToasts } from "@/ui/toast";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
* A send whose reply goes missing may still have gone out. "Send failed ->
|
||||
* Open draft -> Send" then delivered it twice: nothing said which message
|
||||
* the first attempt was, so nothing could check. Each send now carries its
|
||||
* own Message-ID, and an unanswered send asks the server what happened to it
|
||||
* before calling it failed.
|
||||
*/
|
||||
|
||||
type Mode = "ok" | "lost-reply" | "never-arrived" | "orphan" | "gateway" | "refused" | "offline-after";
|
||||
|
||||
interface Server {
|
||||
emails: { id: string; messageId: string }[];
|
||||
submissions: { id: string; emailId: string }[];
|
||||
destroyed: string[];
|
||||
creates: number;
|
||||
}
|
||||
|
||||
function server(mode: Mode): Server {
|
||||
const st: Server = { emails: [], submissions: [], destroyed: [], creates: 0 };
|
||||
let first = true;
|
||||
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const sending = body.methodCalls.some(([n]) => n === "EmailSubmission/set");
|
||||
if (sending && first && mode !== "ok") {
|
||||
first = false;
|
||||
if (mode === "never-arrived") throw new TypeError("Failed to fetch");
|
||||
if (mode === "gateway") return { ok: false, status: 502, statusText: "Bad Gateway", json: async () => ({ error: "bad_gateway" }) } as Response;
|
||||
if (mode === "refused") return { ok: false, status: 400, statusText: "Bad Request", json: async () => ({ type: "urn:ietf:params:jmap:error:notRequest" }) } as Response;
|
||||
// The server does the work; only the answer is lost.
|
||||
const [, args] = body.methodCalls.find(([n, a]) => n === "Email/set" && (a as { create?: unknown }).create)!;
|
||||
const m = (args.create as { m: { messageId: string[] } }).m;
|
||||
const id = `e${st.emails.length + 1}`;
|
||||
st.creates++;
|
||||
st.emails.push({ id, messageId: m.messageId[0]! });
|
||||
if (mode !== "orphan") st.submissions.push({ id: `s${id}`, emailId: id });
|
||||
throw new TypeError("Failed to fetch");
|
||||
}
|
||||
if (mode === "offline-after" && !first) throw new TypeError("Failed to fetch");
|
||||
if (mode === "offline-after" && sending) {
|
||||
first = false;
|
||||
throw new TypeError("Failed to fetch");
|
||||
}
|
||||
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||
if (name === "Email/set" && (args as { create?: unknown }).create) {
|
||||
const m = (args.create as { m: { messageId: string[] } }).m;
|
||||
const eid = `e${st.emails.length + 1}`;
|
||||
st.creates++;
|
||||
st.emails.push({ id: eid, messageId: m.messageId[0]! });
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", created: { m: { id: eid } } }, id];
|
||||
}
|
||||
if (name === "EmailSubmission/set") {
|
||||
const eid = st.emails[st.emails.length - 1]!.id;
|
||||
st.submissions.push({ id: `s${eid}`, emailId: eid });
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", created: { s: { id: `s${eid}` } } }, id];
|
||||
}
|
||||
if (name === "Email/set" && (args as { destroy?: string[] }).destroy) {
|
||||
st.destroyed.push(...((args as { destroy: string[] }).destroy));
|
||||
return [name, { accountId: "a1", oldState: "1", newState: "2", destroyed: (args as { destroy: string[] }).destroy }, id];
|
||||
}
|
||||
if (name === "Email/query") {
|
||||
const [, value] = ((args.filter ?? {}) as { header?: [string, string] }).header ?? [];
|
||||
const ids = value ? st.emails.filter((e) => e.messageId === value && !st.destroyed.includes(e.id)).map((e) => e.id) : [];
|
||||
return [name, { accountId: "a1", queryState: "q", canCalculateChanges: false, position: 0, ids, total: ids.length }, id];
|
||||
}
|
||||
if (name === "EmailSubmission/query") {
|
||||
const want = ((args.filter ?? {}) as { emailIds?: string[] }).emailIds ?? [];
|
||||
const ids = st.submissions.filter((s) => want.includes(s.emailId)).map((s) => s.id);
|
||||
return [name, { accountId: "a1", queryState: "q", canCalculateChanges: false, position: 0, ids, total: ids.length }, id];
|
||||
}
|
||||
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
|
||||
});
|
||||
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
||||
}));
|
||||
return st;
|
||||
}
|
||||
|
||||
const toastTexts = () => useToasts.getState().toasts.map((t) => t.message);
|
||||
|
||||
async function sendOne(init: Record<string, unknown> = {}) {
|
||||
const key = useCompose.getState().open({ to: [{ name: null, email: "[email protected]" }], subject: "Hi", ...init });
|
||||
await useCompose.getState().send(key);
|
||||
return key;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
client.session = {
|
||||
capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.mail]: {}, [CAP.submission]: {} },
|
||||
accounts: { a1: { accountCapabilities: { [CAP.mail]: {}, [CAP.submission]: {} } } },
|
||||
primaryAccounts: {},
|
||||
state: "s1",
|
||||
} as unknown as JmapSession;
|
||||
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, undoSendSeconds: 0 } });
|
||||
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
|
||||
useMail.setState({
|
||||
accountId: "a1",
|
||||
identities: [{ id: "i1", name: "John", email: "[email protected]", replyTo: null }] as never,
|
||||
mailboxes: {
|
||||
mbSent: { id: "mbSent", role: "sent", parentId: null, name: "Sent" },
|
||||
mbDrafts: { id: "mbDrafts", role: "drafts", parentId: null, name: "Drafts" },
|
||||
} as never,
|
||||
});
|
||||
useToasts.setState({ toasts: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("which failures leave the outcome open", () => {
|
||||
it("treats a refusal as not sent, and no answer or a gateway error as unknown", () => {
|
||||
expect(sendOutcomeUnknown(new ApiError(400, "bad"))).toBe(false);
|
||||
expect(sendOutcomeUnknown(new ApiError(401, "unauthenticated"))).toBe(false);
|
||||
expect(sendOutcomeUnknown(new ApiError(502, "bad_gateway"))).toBe(true);
|
||||
expect(sendOutcomeUnknown(new ApiError(408, "timeout"))).toBe(true);
|
||||
expect(sendOutcomeUnknown(new TypeError("Failed to fetch"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sending once, whatever goes wrong on the way back", () => {
|
||||
it("gives every send a Message-ID on the sender's domain", async () => {
|
||||
const st = server("ok");
|
||||
await sendOne();
|
||||
expect(st.emails).toHaveLength(1);
|
||||
expect(st.emails[0]!.messageId).toMatch(/^[0-9a-f-]{36}@example\.org$/);
|
||||
expect(toastTexts()).toContain("Message sent");
|
||||
});
|
||||
|
||||
it("calls it sent when the reply was lost but the server sent it", async () => {
|
||||
const st = server("lost-reply");
|
||||
await sendOne();
|
||||
expect(st.submissions).toHaveLength(1);
|
||||
expect(st.destroyed).toEqual([]);
|
||||
expect(toastTexts()).toContain("Message sent");
|
||||
expect(useCompose.getState().drafts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removes a message that was created but never submitted, and says it failed", async () => {
|
||||
const st = server("orphan");
|
||||
await sendOne();
|
||||
expect(st.destroyed).toEqual(["e1"]);
|
||||
expect(toastTexts().some((t) => t.startsWith("Send failed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("says it failed when the request never reached the server", async () => {
|
||||
const st = server("never-arrived");
|
||||
await sendOne();
|
||||
expect(st.emails).toHaveLength(0);
|
||||
expect(toastTexts().some((t) => t.startsWith("Send failed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("asks the server after a gateway error too, rather than assuming", async () => {
|
||||
const st = server("gateway");
|
||||
await sendOne();
|
||||
expect(st.creates).toBe(0);
|
||||
expect(toastTexts().some((t) => t.startsWith("Send failed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not ask after a refusal: the server did not run it", async () => {
|
||||
server("refused");
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
await sendOne();
|
||||
const asked = fetchMock.mock.calls.some(([, init]) => (init as RequestInit).body?.toString().includes("Email/query"));
|
||||
expect(asked).toBe(false);
|
||||
});
|
||||
|
||||
it("says plainly when it cannot tell, instead of offering a resend that could duplicate", async () => {
|
||||
server("offline-after");
|
||||
await sendOne();
|
||||
const msgs = toastTexts();
|
||||
expect(msgs.some((t) => t.includes("Couldn't confirm whether this message was sent"))).toBe(true);
|
||||
expect(msgs.some((t) => t.startsWith("Send failed"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sending a draft again after a failure", () => {
|
||||
it("keeps the Message-ID, and sends nothing when the first attempt went out", async () => {
|
||||
const st = server("ok");
|
||||
// The first attempt went out; the client never heard.
|
||||
st.emails.push({ id: "e1", messageId: "[email protected]" });
|
||||
st.submissions.push({ id: "se1", emailId: "e1" });
|
||||
await sendOne({ sendMessageId: "[email protected]" });
|
||||
expect(st.creates).toBe(0);
|
||||
expect(toastTexts().some((t) => t.includes("had already been sent"))).toBe(true);
|
||||
});
|
||||
|
||||
it("sends it, under the same Message-ID, when the first attempt did not go out", async () => {
|
||||
const st = server("ok");
|
||||
await sendOne({ sendMessageId: "[email protected]" });
|
||||
expect(st.creates).toBe(1);
|
||||
expect(st.emails[0]!.messageId).toBe("[email protected]");
|
||||
expect(toastTexts()).toContain("Message sent");
|
||||
});
|
||||
|
||||
it("reopens a failed draft with its Message-ID, so the next send can check", async () => {
|
||||
server("orphan");
|
||||
await sendOne();
|
||||
const toast = useToasts.getState().toasts.find((t) => t.message.startsWith("Send failed"))!;
|
||||
toast.action!.onClick();
|
||||
const reopened = useCompose.getState().drafts[0]!;
|
||||
expect(reopened.sendMessageId).toMatch(/@example\.org$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
import { useSession, viewingDelegation } from "@/store/session";
|
||||
import { findSharedMail, sharedMailCandidates } from "@/lib/sharedMail";
|
||||
import { delegatedAccounts } from "@/lib/delegation";
|
||||
|
||||
/**
|
||||
* MA-A: a group's mailbox, or folders someone shared, can be opened in place
|
||||
* of the reader's own mail. Only accounts that answer with a mailbox are
|
||||
* offered, only mail follows the switch, and losing the account takes the
|
||||
* reader back.
|
||||
*/
|
||||
|
||||
const sessionWith = (extra: Record<string, unknown> = {}) =>
|
||||
({
|
||||
capabilities: { [CAP.core]: { maxCallsInRequest: 16, maxObjectsInGet: 500 }, [CAP.mail]: {} },
|
||||
accounts: {
|
||||
own: {
|
||||
name: "[email protected]",
|
||||
isPersonal: true,
|
||||
accountCapabilities: { [CAP.mail]: {}, [CAP.calendars]: {}, [CAP.contacts]: {}, [CAP.filenode]: {} },
|
||||
},
|
||||
// A group: every capability, and mailboxes
|
||||
group: {
|
||||
name: "[email protected]",
|
||||
isPersonal: false,
|
||||
accountCapabilities: { [CAP.mail]: {}, [CAP.calendars]: {}, [CAP.contacts]: {}, [CAP.filenode]: {} },
|
||||
},
|
||||
// Someone who shared one calendar: mail is advertised, but no mailbox answers
|
||||
calendarOnly: { name: "[email protected]", isPersonal: false, accountCapabilities: { [CAP.mail]: {}, [CAP.calendars]: {} } },
|
||||
// No mail at all
|
||||
filesOnly: { name: "[email protected]", isPersonal: false, accountCapabilities: { [CAP.filenode]: {} } },
|
||||
// A locked account handed over: listed by delegation.ts, never here
|
||||
locked: {
|
||||
name: "[email protected]",
|
||||
isPersonal: false,
|
||||
accountCapabilities: { [CAP.mail]: {}, "urn:inbuxa:jmap": { delegation: { locked: true, access: "read", sendAs: false, until: null } } },
|
||||
},
|
||||
// A shared mailbox an administrator assigned (MA-S): marked, so never asked about
|
||||
desk: {
|
||||
name: "[email protected]",
|
||||
isPersonal: false,
|
||||
accountCapabilities: {
|
||||
[CAP.mail]: {},
|
||||
[CAP.calendars]: {},
|
||||
"urn:inbuxa:jmap": { delegation: { locked: true, kind: "sharedMailbox", access: "organize", sendAs: true, until: null } },
|
||||
},
|
||||
},
|
||||
...extra,
|
||||
},
|
||||
primaryAccounts: { [CAP.mail]: "own", [CAP.calendars]: "own", [CAP.contacts]: "own", [CAP.filenode]: "own" },
|
||||
state: "s",
|
||||
}) as unknown as JmapSession;
|
||||
|
||||
/** Accounts whose Mailbox/get answers with a mailbox. */
|
||||
let withMailboxes = new Set(["group"]);
|
||||
let nextSession: JmapSession;
|
||||
|
||||
beforeEach(() => {
|
||||
withMailboxes = new Set(["group"]);
|
||||
nextSession = sessionWith();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (String(url).includes("/api/auth/session")) {
|
||||
return { ok: true, status: 200, json: async () => nextSession } as Response;
|
||||
}
|
||||
const { methodCalls } = JSON.parse((init?.body as string) ?? "{}") as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const methodResponses = methodCalls.map(([name, args, id]) => [
|
||||
name,
|
||||
{ accountId: args.accountId, state: "1", list: withMailboxes.has(String(args.accountId)) ? [{ id: "a" }] : [], notFound: [] },
|
||||
id,
|
||||
]);
|
||||
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response;
|
||||
}),
|
||||
);
|
||||
const session = sessionWith();
|
||||
client.session = session;
|
||||
useSession.setState({ status: "authenticated", session, accountId: "own", viewing: null, sharedMail: [], delegationEnded: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useSession.setState({ viewing: null, sharedMail: [] });
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("shared mail", () => {
|
||||
it("considers only other people's accounts that advertise mail, leaving locked accounts to delegation", () => {
|
||||
expect(sharedMailCandidates(sessionWith()).map((a) => a.id)).toEqual(["calendarOnly", "desk", "group"]);
|
||||
});
|
||||
|
||||
it("offers only accounts that answer with a mailbox", async () => {
|
||||
// The shared mailbox answers no Mailbox/get here, and is offered anyway
|
||||
expect((await findSharedMail(sessionWith())).map((a) => a.name)).toEqual(["[email protected]", "[email protected]"]);
|
||||
});
|
||||
|
||||
it("can't be opened until it is found, and then shows mail only", async () => {
|
||||
useSession.getState().view("group");
|
||||
expect(useSession.getState().viewing).toBeNull();
|
||||
|
||||
await useSession.getState().loadSharedMail();
|
||||
useSession.getState().view("group");
|
||||
expect(useSession.getState().viewing).toBe("group");
|
||||
// Calendars, contacts and files stay the reader's own
|
||||
expect(useSession.getState().viewAccountFor(CAP.calendars)).toBe("own");
|
||||
expect(useSession.getState().viewAccountFor(CAP.contacts)).toBe("own");
|
||||
expect(useSession.getState().viewAccountFor(CAP.filenode)).toBe("own");
|
||||
|
||||
// An account with no mailboxes is never offered
|
||||
useSession.getState().view("calendarOnly");
|
||||
expect(useSession.getState().viewing).toBe("group");
|
||||
});
|
||||
|
||||
it("takes the reader back to their own mail when the account goes away", async () => {
|
||||
await useSession.getState().loadSharedMail();
|
||||
useSession.getState().view("group");
|
||||
const session = sessionWith();
|
||||
delete (session.accounts as Record<string, unknown>).group;
|
||||
nextSession = session;
|
||||
withMailboxes = new Set();
|
||||
await useSession.getState().refresh();
|
||||
expect(useSession.getState().viewing).toBeNull();
|
||||
expect(useSession.getState().delegationEnded).toBe("[email protected]");
|
||||
});
|
||||
|
||||
it("shows an assigned shared mailbox as shared, not locked, keeping its access level", async () => {
|
||||
await useSession.getState().loadSharedMail();
|
||||
expect(delegatedAccounts(useSession.getState().session).map((a) => a.id)).toEqual(["locked"]);
|
||||
useSession.getState().view("desk");
|
||||
expect(useSession.getState().viewing).toBe("desk");
|
||||
expect(viewingDelegation()?.access).toBe("organize");
|
||||
// Mail only, like any shared mailbox
|
||||
expect(useSession.getState().viewAccountFor(CAP.calendars)).toBe("own");
|
||||
});
|
||||
});
|
||||
+120
-6
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { client, setErrorMessage } from "@/jmap/client";
|
||||
import { ApiError, client, setErrorMessage } from "@/jmap/client";
|
||||
import type { Email, EmailAddress, EmailBodyPart, Id, Identity, SetResponse } from "@/jmap/types";
|
||||
import { formatFullDate, uid } from "@/lib/format";
|
||||
import { formatAddress, parseMailto, sameAddress, uniqueAddresses } from "@/lib/address";
|
||||
@@ -102,6 +102,12 @@ export interface Draft {
|
||||
dlp?: DlpRefusalInfo | null;
|
||||
/** The reason to send despite a DLP warning, for the next send only. */
|
||||
dlpOverride?: string | null;
|
||||
/**
|
||||
* The Message-ID this draft goes out under, fixed at its first send and
|
||||
* kept if the draft comes back after a failure. Sending it again asks the
|
||||
* server first whether a message with this id already went out.
|
||||
*/
|
||||
sendMessageId?: string;
|
||||
}
|
||||
|
||||
export interface DlpRefusalInfo {
|
||||
@@ -738,8 +744,12 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
},
|
||||
|
||||
async send(key) {
|
||||
const d = get().drafts.find((x) => x.key === key);
|
||||
if (!d) return;
|
||||
const found = get().drafts.find((x) => x.key === key);
|
||||
if (!found) return;
|
||||
// A draft that already has a Message-ID has been sent before, and failed.
|
||||
const retry = Boolean(found.sendMessageId);
|
||||
const fromEmail = (useMail.getState().identities.find((i) => i.id === found.identityId) ?? useMail.getState().identities[0])?.email ?? "";
|
||||
const d: Draft = { ...found, sendMessageId: found.sendMessageId ?? newMessageId(fromEmail) };
|
||||
const delay = settings().undoSendSeconds;
|
||||
// A schedule the user left sitting until it passed is just a send now.
|
||||
const scheduling = d.sendAt !== null && d.sendAt > Date.now();
|
||||
@@ -754,7 +764,18 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
return { pendingSends: rest };
|
||||
});
|
||||
try {
|
||||
const sent = await sendInternal(d, get);
|
||||
const sent = await sendInternal(d, get, { retry });
|
||||
// Everyone written to who is not a contact yet, so they are suggested on every
|
||||
// device. Also when a resend found the first attempt had gone out: its reply
|
||||
// was what got lost, so nothing was collected then.
|
||||
if (settings().collectRecipients) {
|
||||
const own = useMail.getState().identities.map((i) => i.email);
|
||||
void useContacts.getState().collectRecipients([...d.to, ...d.cc, ...d.bcc], own).catch(() => undefined);
|
||||
}
|
||||
if (sent.alreadySent) {
|
||||
toast.success(translate("This message had already been sent, so it wasn't sent again."));
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
sent.held
|
||||
? translate("Held for review: it's sent once a reviewer releases it")
|
||||
@@ -769,6 +790,13 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
||||
toast.error(err.info.kind === "warning" ? translate("Not sent yet: check the warning") : translate("Not sent: blocked by the server's rules"));
|
||||
return;
|
||||
}
|
||||
if (err instanceof SendOutcomeUnknown) {
|
||||
toast.error(err.message, {
|
||||
action: { label: translate("Open draft"), onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: err.message }], activeKey: d.key })) },
|
||||
duration: 30000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.error(translate("Send failed: {error}", { error: (err as Error).message }), {
|
||||
action: { label: translate("Open draft"), onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: (err as Error).message }], activeKey: d.key })) },
|
||||
duration: 15000,
|
||||
@@ -1082,9 +1110,54 @@ export function buildSubmission(opts: {
|
||||
/** What the server said of a send: whether DLP held it for review (inbuxa). */
|
||||
interface Sent {
|
||||
held: boolean;
|
||||
/** A resend found the first attempt had already gone out; nothing was sent again. */
|
||||
alreadySent?: boolean;
|
||||
}
|
||||
|
||||
async function sendInternal(d: Draft, _get: () => ComposeState): Promise<Sent> {
|
||||
/**
|
||||
* A send whose outcome nobody can state: the request went out, no answer came
|
||||
* back, and the server could not be asked afterwards either. Offering a plain
|
||||
* "send again" here is how one message is delivered twice.
|
||||
*/
|
||||
export class SendOutcomeUnknown extends Error {}
|
||||
|
||||
/** A Message-ID for one send, on the sending identity's domain (RFC 5322 §3.6.4). */
|
||||
export function newMessageId(fromEmail: string): string {
|
||||
const domain = fromEmail.split("@")[1] || "localhost";
|
||||
return `${crypto.randomUUID()}@${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a failed request may still have been carried out. A refusal (4xx)
|
||||
* means the server did not run it; no answer, or a 5xx from the proxy
|
||||
* that lost the upstream reply, means it may have.
|
||||
*/
|
||||
export function sendOutcomeUnknown(err: unknown): boolean {
|
||||
if (err instanceof ApiError) return err.status >= 500 || err.status === 408;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* What became of a send with this Message-ID.
|
||||
*
|
||||
* `sent`: a message carrying it exists and was submitted. `orphan`: it exists
|
||||
* but no submission does, so it was created and never sent. `none`: nothing.
|
||||
*
|
||||
* Submissions are expunged after the server's hold period, so "no submission"
|
||||
* means unsent only right after the attempt (`justNow`). Later, a message
|
||||
* still carrying the id is taken as sent: every path that fails a send
|
||||
* destroys the copy it created.
|
||||
*/
|
||||
export async function traceSend(accountId: Id, messageId: string, justNow: boolean): Promise<{ trace: "sent" | "orphan" | "none"; emailIds: Id[] }> {
|
||||
const q = await client.call<{ ids: Id[] }>("Email/query", { accountId, filter: { header: ["Message-ID", messageId] } });
|
||||
const emailIds = q.ids ?? [];
|
||||
if (!emailIds.length) return { trace: "none", emailIds };
|
||||
if (!justNow) return { trace: "sent", emailIds };
|
||||
const subs = await client.call<{ ids: Id[] }>("EmailSubmission/query", { accountId, filter: { emailIds } });
|
||||
return { trace: subs.ids?.length ? "sent" : "orphan", emailIds };
|
||||
}
|
||||
|
||||
async function sendInternal(d: Draft, _get: () => ComposeState, opts: { retry?: boolean } = {}): Promise<Sent> {
|
||||
const mail = useMail.getState();
|
||||
const accountId = mail.accountId!;
|
||||
const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0];
|
||||
@@ -1093,6 +1166,27 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<Sent> {
|
||||
const scheduled = d.sendAt !== null && d.sendAt > Date.now();
|
||||
const scheduledId = scheduled ? await ensureScheduledMailbox() : null;
|
||||
const email = await buildEmailObject(d, { forSend: true, mailboxId: scheduledId });
|
||||
const messageId = d.sendMessageId ?? newMessageId(ident.email);
|
||||
email.messageId = [messageId];
|
||||
/*
|
||||
* Sending again a draft that came back from a failed send: ask first. The
|
||||
* failure may have been only the reply going missing, and a second
|
||||
* submission would deliver the message twice.
|
||||
*/
|
||||
if (opts.retry) {
|
||||
let found: Awaited<ReturnType<typeof traceSend>>;
|
||||
try {
|
||||
found = await traceSend(accountId, messageId, false);
|
||||
} catch {
|
||||
throw new SendOutcomeUnknown(translate("Couldn't check whether this message was already sent. Check Sent before sending it again."));
|
||||
}
|
||||
if (found.trace === "sent") {
|
||||
if (d.draftId) void client.call("Email/set", { accountId, destroy: [d.draftId] });
|
||||
void mail.loadMailboxes();
|
||||
void mail.refreshList();
|
||||
return { held: false, alreadySent: true };
|
||||
}
|
||||
}
|
||||
const sentId = mail.roleId("sent");
|
||||
const draftsId = mail.roleId("drafts");
|
||||
const rcpts = uniqueAddresses([...d.to, ...d.cc, ...d.bcc]).map((a) => ({ email: a.email }));
|
||||
@@ -1119,7 +1213,27 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<Sent> {
|
||||
if (d.relatedEmailId && d.relatedKeyword) {
|
||||
calls.push(["Email/set", { accountId, update: { [d.relatedEmailId]: { [`keywords/${d.relatedKeyword}`]: true } } }, "k"]);
|
||||
}
|
||||
const res = await client.chain(calls, { allowErrors: true });
|
||||
let res: Awaited<ReturnType<typeof client.chain>>;
|
||||
try {
|
||||
res = await client.chain(calls, { allowErrors: true });
|
||||
} catch (err) {
|
||||
if (!sendOutcomeUnknown(err)) throw err;
|
||||
// No answer is not a no: find out what the server did with it.
|
||||
let found: Awaited<ReturnType<typeof traceSend>>;
|
||||
try {
|
||||
found = await traceSend(accountId, messageId, true);
|
||||
} catch {
|
||||
throw new SendOutcomeUnknown(translate("Couldn't confirm whether this message was sent. Check Sent before sending it again."));
|
||||
}
|
||||
if (found.trace === "sent") {
|
||||
void mail.loadMailboxes();
|
||||
void mail.refreshList();
|
||||
return { held: false };
|
||||
}
|
||||
// Created but never submitted: take it out of Sent, then report the failure.
|
||||
if (found.trace === "orphan") void client.call("Email/set", { accountId, destroy: found.emailIds });
|
||||
throw err;
|
||||
}
|
||||
const e = res.get("e")?.[0] as unknown as SetResponse<Email> & { __error?: { type: string; description?: string } };
|
||||
if (e.__error) throw new Error(setErrorMessage(e.__error));
|
||||
if (e.notCreated?.m) throw new Error(setErrorMessage(e.notCreated.m));
|
||||
|
||||
@@ -2,10 +2,12 @@ import { create } from "zustand";
|
||||
import { accountKey, loadRaw, saveJson } from "@/lib/storage";
|
||||
import { CAP, chunk, client, JmapMethodError, setErrorMessage } from "@/jmap/client";
|
||||
import type { AddressBook, ChangesResponse, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetError, SetResponse } from "@/jmap/types";
|
||||
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
|
||||
import { contactDisplayName, contactEmails, contactFromAddress, sortKey } from "@/lib/contacts";
|
||||
import { uniqueAddresses } from "@/lib/address";
|
||||
import { parseLdif, uidFromDn } from "@/lib/ldif";
|
||||
import { cardFromLdif } from "@/lib/mozillaAb";
|
||||
import { useSettings } from "./settings";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
import { useSession } from "./session";
|
||||
import { useMail } from "./mail";
|
||||
|
||||
@@ -225,6 +227,8 @@ interface ContactsState {
|
||||
*/
|
||||
emptyBook(bookId: Id): Promise<{ destroyed: number; unfiled: number; refused?: SetError }>;
|
||||
createBook(name: string): Promise<Id>;
|
||||
/** Save the addresses that are not on any contact yet, in the Collected address book. */
|
||||
collectRecipients(addrs: EmailAddress[], own: string[]): Promise<number>;
|
||||
updateBook(id: Id, patch: Partial<AddressBook>): Promise<void>;
|
||||
destroyBook(id: Id): Promise<void>;
|
||||
/** Import vCards, updating any whose UID this book already holds rather than duplicating it. */
|
||||
@@ -616,6 +620,28 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
return { destroyed: gone.length, unfiled, refused };
|
||||
},
|
||||
|
||||
async collectRecipients(addrs, own) {
|
||||
if (!get().available || !get().accountId) return 0;
|
||||
// Every card has to be known, or someone already a contact gets a second card.
|
||||
if (!get().loaded) await get().loadAll();
|
||||
const ownSet = new Set(own.map((e) => e.toLowerCase()));
|
||||
const fresh = uniqueAddresses(addrs).filter((a) => a.email && !ownSet.has(a.email.toLowerCase()) && !get().lookupByEmail(a.email));
|
||||
if (!fresh.length) return 0;
|
||||
const accountId = get().accountId!;
|
||||
const { settings, update } = useSettings.getState();
|
||||
let bookId = settings.collectedBookId && get().books[settings.collectedBookId] ? settings.collectedBookId : null;
|
||||
if (!bookId) {
|
||||
bookId = await get().createBook(translate("Collected"));
|
||||
update({ collectedBookId: bookId });
|
||||
}
|
||||
const create = Object.fromEntries(
|
||||
fresh.map((a, i) => [`c${i}`, { "@type": "Card", version: "1.0", uid: crypto.randomUUID(), kind: "individual", ...contactFromAddress(a), addressBookIds: { [bookId!]: true } }]),
|
||||
);
|
||||
const res = await client.call<SetResponse<ContactCard>>("ContactCard/set", { accountId, create });
|
||||
await get().syncCards();
|
||||
return Object.keys(res.created ?? {}).length;
|
||||
},
|
||||
|
||||
async createBook(name) {
|
||||
const accountId = get().accountId!;
|
||||
const res = await client.call<SetResponse<AddressBook>>("AddressBook/set", { accountId, create: { b: { name } } });
|
||||
@@ -793,14 +819,29 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
seen.add(k);
|
||||
out.push(s);
|
||||
};
|
||||
const score = (name: string | null, email: string): number => {
|
||||
/*
|
||||
* Words in any order: "jane smi" finds "Smith, Jane", and a nickname or the
|
||||
* organization counts as much as the name. Each word typed has to start a
|
||||
* word of the person's name, nickname, organization or address.
|
||||
*/
|
||||
const tokens = q.split(/\s+/).filter(Boolean);
|
||||
const wordsOf = (parts: Array<string | null | undefined>) =>
|
||||
parts.flatMap((p) => (p ?? "").toLowerCase().split(/[\s,.;:@_+()<>"'-]+/)).filter(Boolean);
|
||||
const score = (name: string | null, email: string, extra: Array<string | null | undefined> = []): number => {
|
||||
const n = (name ?? "").toLowerCase();
|
||||
const e = email.toLowerCase();
|
||||
if (e.startsWith(q) || n.startsWith(q)) return 0;
|
||||
if (n.split(/\s+/).some((w) => w.startsWith(q))) return 1;
|
||||
const words = wordsOf([name, email, ...extra]);
|
||||
if (tokens.every((t) => words.some((w) => w.startsWith(t)))) return 1;
|
||||
if (e.includes(q) || n.includes(q)) return 2;
|
||||
return 99;
|
||||
};
|
||||
// Someone written to lately ranks a little above someone not, within the same kind of match.
|
||||
const recentRank = new Map(st.recent.map((r, i) => [r.email.toLowerCase(), i] as const));
|
||||
const recency = (email: string) => {
|
||||
const i = recentRank.get(email.toLowerCase());
|
||||
return i === undefined ? 0 : -0.3 * (1 - i / Math.max(1, st.recent.length));
|
||||
};
|
||||
const candidates: Array<Suggestion & { score: number }> = [];
|
||||
// A shared address book is only useful if it answers when you are writing
|
||||
// to someone in it, so its cards are offered alongside the reader's own.
|
||||
@@ -808,19 +849,20 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
const own = Object.values(st.cards).map((c) => ({ c, penalty: 0 }));
|
||||
const shared = Object.values(st.sharedCards).map((c) => ({ c, penalty: 0.5 }));
|
||||
for (const { c, penalty } of [...own, ...shared]) {
|
||||
const extra = [...Object.values(c.nicknames ?? {}).map((x) => x.name), ...Object.values(c.organizations ?? {}).map((x) => x.name)];
|
||||
for (const a of contactEmails(c)) {
|
||||
const sc = score(a.name, a.email);
|
||||
if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc + penalty });
|
||||
const sc = score(a.name, a.email, extra);
|
||||
if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc + penalty + recency(a.email) });
|
||||
}
|
||||
}
|
||||
for (const p of st.principals) {
|
||||
if (!p.email) continue;
|
||||
const sc = score(p.name, p.email);
|
||||
if (sc < 99) candidates.push({ name: p.name, email: p.email, source: "gal", score: sc + 0.5 });
|
||||
if (sc < 99) candidates.push({ name: p.name, email: p.email, source: "gal", score: sc + 0.5 + recency(p.email) });
|
||||
}
|
||||
for (const r of st.recent) {
|
||||
const sc = score(r.name, r.email);
|
||||
if (sc < 99) candidates.push({ name: r.name, email: r.email, source: "recent", score: sc + 0.25 });
|
||||
if (sc < 99) candidates.push({ name: r.name, email: r.email, source: "recent", score: sc + 0.25 + recency(r.email) });
|
||||
}
|
||||
candidates.sort((a, b) => a.score - b.score || (a.name ?? a.email).localeCompare(b.name ?? b.email));
|
||||
for (const c of candidates) {
|
||||
|
||||
+133
-15
@@ -6,10 +6,12 @@ import { accountForCapability, ownAccountForCapability } from "@/lib/accountRout
|
||||
import { setServerLocale } from "@/lib/datetime";
|
||||
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
|
||||
import { reloadIfServerRebuilt } from "@/lib/sw/staleBuild";
|
||||
import { unsubscribeThisDevice } from "@/lib/notify/webpush";
|
||||
import { unsubscribeAccount, unsubscribeThisDevice } from "@/lib/notify/webpush";
|
||||
import { clearAllData, clearSignedInData, setDeviceTrusted } from "@/lib/storage";
|
||||
import { startIdleLogout, stopIdleLogout } from "@/lib/idleLogout";
|
||||
import { delegationOf, type Delegation } from "@/lib/delegation";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
import { findSharedMail, sharedMailCandidates, type SharedMailAccount } from "@/lib/sharedMail";
|
||||
|
||||
export type AuthStatus = "loading" | "anonymous" | "authenticated";
|
||||
|
||||
@@ -19,12 +21,19 @@ interface SessionState {
|
||||
/** Selected mail account (defaults to primary). */
|
||||
accountId: Id | null;
|
||||
/**
|
||||
* A locked account handed to the reader that the app shows instead of
|
||||
* their own (inbuxa AL-7): its mail, calendar, contacts and files. The
|
||||
* reader's settings, filters, signatures and push stay their own.
|
||||
* Another account whose mail the app shows instead of the reader's own:
|
||||
* a locked account handed to them (inbuxa AL-7), whose calendar, contacts
|
||||
* and files follow, or a shared or group mailbox (MA-A), which is mail
|
||||
* only. The reader's settings, filters, signatures and push stay their own.
|
||||
*/
|
||||
viewing: Id | null;
|
||||
/** The name of an account whose delegation ended while it was in view. */
|
||||
/** Shared and group mailboxes the reader can open (MA-A); see lib/sharedMail. */
|
||||
sharedMail: SharedMailAccount[];
|
||||
/** inbuxa MA-B: the accounts signed in in this browser, the one in front first. */
|
||||
signedIn: SignedInAccount[];
|
||||
/** Whether one more may be added (the cap, and both organizations' addAccounts). */
|
||||
canAddAccount: boolean;
|
||||
/** The name of an account the reader lost while it was in view. */
|
||||
delegationEnded: string | null;
|
||||
error: string | null;
|
||||
pushConnected: boolean;
|
||||
@@ -32,11 +41,20 @@ interface SessionState {
|
||||
pushState: PushState;
|
||||
bootstrap(): Promise<void>;
|
||||
login(username: string, password: string, totp: string, remember: boolean): Promise<void>;
|
||||
/** Signs out of the account in front; another signed-in one comes forward. */
|
||||
logout(): Promise<void>;
|
||||
/** inbuxa MA-B: ends every account signed in in this browser. */
|
||||
logoutAll(): Promise<void>;
|
||||
/** inbuxa MA-B: finds the other accounts signed in here. */
|
||||
loadSignedIn(): Promise<void>;
|
||||
/** inbuxa MA-B: brings another signed-in account to the front, and reloads. */
|
||||
switchTo(sessionId: string): Promise<void>;
|
||||
refresh(): Promise<void>;
|
||||
setAccount(id: Id): void;
|
||||
/** Show a delegated account's mail, or the reader's own with null. */
|
||||
/** Show a delegated account's or shared mailbox's mail, or the reader's own with null. */
|
||||
view(id: Id | null): void;
|
||||
/** Finds the shared and group mailboxes the reader can open. */
|
||||
loadSharedMail(): Promise<void>;
|
||||
clearDelegationEnded(): void;
|
||||
/** The account to read and write for a capability, honoring the account switcher. */
|
||||
accountFor(cap: string): Id | null;
|
||||
@@ -53,11 +71,49 @@ interface SessionState {
|
||||
|
||||
let refreshing: Promise<void> | null = null;
|
||||
|
||||
/** What a signed-in account looks like in the switcher (MA-B). */
|
||||
export interface SignedInAccount {
|
||||
id: string;
|
||||
username: string;
|
||||
front: boolean;
|
||||
/** Its mail account, for its push subscription (MA-8); null when not known yet. */
|
||||
mailAccountId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* inbuxa MA-8: a notification for an account not in front opens
|
||||
* `?account=<session>&next=<where>`: bring that account forward, then go
|
||||
* there. Only a path inside the app is followed.
|
||||
*/
|
||||
// Read as the app starts: the router sends `/` on to `/mail` without its query.
|
||||
let launchParams: URLSearchParams | null = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null;
|
||||
|
||||
async function openFromNotification(accounts: SignedInAccount[]): Promise<void> {
|
||||
const params = launchParams;
|
||||
launchParams = null;
|
||||
const wanted = params?.get("account");
|
||||
if (!params || !wanted) return;
|
||||
const raw = params.get("next") ?? "";
|
||||
const next = raw.startsWith("/") && !raw.startsWith("//") ? raw : withBase("/mail");
|
||||
const account = accounts.find((a) => a.id === wanted);
|
||||
if (account && !account.front) {
|
||||
await apiFetch(`/api/auth/accounts/${encodeURIComponent(wanted)}/front`, { method: "POST" });
|
||||
clearSignedInData();
|
||||
}
|
||||
window.location.replace(next);
|
||||
}
|
||||
|
||||
/** Which sign-out: the account in front, or every one (MA-B). */
|
||||
let signOutPath = "/api/auth/logout";
|
||||
|
||||
export const useSession = create<SessionState>((set, get) => ({
|
||||
status: "loading",
|
||||
session: null,
|
||||
accountId: null,
|
||||
viewing: null,
|
||||
sharedMail: [],
|
||||
signedIn: [],
|
||||
canAddAccount: false,
|
||||
delegationEnded: null,
|
||||
error: null,
|
||||
pushConnected: false,
|
||||
@@ -96,7 +152,19 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
// without removing it leaves this browser notifying for a mailbox nobody is
|
||||
// signed into. On a shared machine that is somebody else's mail.
|
||||
try {
|
||||
await unsubscribeThisDevice();
|
||||
const everyone = signOutPath.endsWith("logout-all");
|
||||
const othersRemain = !everyone && get().signedIn.some((a) => !a.front);
|
||||
if (everyone) {
|
||||
// inbuxa MA-8: every account's subscription goes, the others' first
|
||||
const { unsubscribeOtherAccounts } = await import("@/lib/notify/webpushEnable");
|
||||
await unsubscribeOtherAccounts();
|
||||
}
|
||||
if (othersRemain) {
|
||||
// inbuxa MA-8: only this account's; the browser keeps notifying for the rest
|
||||
await unsubscribeAccount(undefined, get().ownAccountFor(CAP.mail));
|
||||
} else {
|
||||
await unsubscribeThisDevice();
|
||||
}
|
||||
} catch {
|
||||
/* never block signing out over this */
|
||||
}
|
||||
@@ -109,8 +177,11 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
} catch {
|
||||
/* never block signing out over this */
|
||||
}
|
||||
const path = signOutPath;
|
||||
signOutPath = "/api/auth/logout";
|
||||
let next = false;
|
||||
try {
|
||||
await apiFetch("/api/auth/logout", { method: "POST" });
|
||||
next = Boolean((await apiFetch<{ next?: boolean }>(path, { method: "POST" }))?.next);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -120,7 +191,35 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
// problem next -- and the address book cached here is the same argument.
|
||||
clearSignedInData();
|
||||
client.session = null;
|
||||
set({ status: "anonymous", session: null, accountId: null, viewing: null });
|
||||
// inbuxa MA-B: another signed-in account is in front now
|
||||
if (next) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
set({ status: "anonymous", session: null, accountId: null, viewing: null, sharedMail: [], signedIn: [], canAddAccount: false });
|
||||
},
|
||||
|
||||
async logoutAll() {
|
||||
// The same care as signing out of one, then every account ends
|
||||
signOutPath = "/api/auth/logout-all";
|
||||
await get().logout();
|
||||
},
|
||||
|
||||
async loadSignedIn() {
|
||||
try {
|
||||
const answer = await apiFetch<{ accounts: SignedInAccount[]; canAdd: boolean }>("/api/auth/accounts");
|
||||
set({ signedIn: answer.accounts, canAddAccount: answer.canAdd });
|
||||
await openFromNotification(answer.accounts);
|
||||
} catch {
|
||||
set({ signedIn: [], canAddAccount: false });
|
||||
}
|
||||
},
|
||||
|
||||
async switchTo(sessionId) {
|
||||
await apiFetch(`/api/auth/accounts/${encodeURIComponent(sessionId)}/front`, { method: "POST" });
|
||||
// What was cached belongs to the account that was in front
|
||||
clearSignedInData();
|
||||
window.location.reload();
|
||||
},
|
||||
|
||||
refresh() {
|
||||
@@ -130,14 +229,16 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
const s = await apiFetch<JmapSession>("/api/auth/session?refresh=1");
|
||||
client.session = s;
|
||||
setServerLocale(s.ihasmail?.userLocale);
|
||||
// A delegation that ended takes the reader back to their own mail
|
||||
// A delegation that ended, or a shared mailbox taken away, takes the
|
||||
// reader back to their own mail
|
||||
const viewing = get().viewing;
|
||||
if (viewing && !delegationOf(s, viewing)) {
|
||||
if (viewing && !delegationOf(s, viewing) && !sharedMailCandidates(s).some((a) => a.id === viewing)) {
|
||||
const name = get().session?.accounts[viewing]?.name ?? null;
|
||||
set({ session: s, viewing: null, delegationEnded: name });
|
||||
} else {
|
||||
set({ session: s });
|
||||
}
|
||||
void get().loadSharedMail();
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
@@ -152,11 +253,18 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
},
|
||||
|
||||
view(id) {
|
||||
if (id && !delegationOf(get().session, id)) return;
|
||||
if (id && !delegationOf(get().session, id) && !get().sharedMail.some((a) => a.id === id)) return;
|
||||
if (id === get().viewing) return;
|
||||
set({ viewing: id });
|
||||
},
|
||||
|
||||
async loadSharedMail() {
|
||||
const session = get().session;
|
||||
const found = await findSharedMail(session);
|
||||
// A sign-out or another account's session arrived while it was asking
|
||||
if (get().session === session) set({ sharedMail: found });
|
||||
},
|
||||
|
||||
clearDelegationEnded() {
|
||||
set({ delegationEnded: null });
|
||||
},
|
||||
@@ -172,7 +280,8 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
viewAccountFor(cap) {
|
||||
const { session, viewing } = get();
|
||||
const viewed = viewing ? session?.accounts[viewing] : undefined;
|
||||
if (viewing && viewed && cap in (viewed.accountCapabilities ?? {})) return viewing;
|
||||
// A shared or group mailbox in view is mail only (MA-A, MA-S)
|
||||
if (viewing && viewed && delegationOf(session, viewing)?.kind === "lock" && cap in (viewed.accountCapabilities ?? {})) return viewing;
|
||||
return ownAccountForCapability(session, cap);
|
||||
},
|
||||
}));
|
||||
@@ -194,7 +303,9 @@ function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
|
||||
startIdleLogout(() => void useSession.getState().logout());
|
||||
}
|
||||
const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null;
|
||||
set({ status: "authenticated", session: s, accountId, viewing: null, error: null });
|
||||
set({ status: "authenticated", session: s, accountId, viewing: null, sharedMail: [], error: null });
|
||||
void useSession.getState().loadSharedMail();
|
||||
void useSession.getState().loadSignedIn();
|
||||
}
|
||||
|
||||
client.onUnauthenticated(() => {
|
||||
@@ -207,7 +318,7 @@ client.onUnauthenticated(() => {
|
||||
// usual reason to be signed out here, and reloading a form someone has
|
||||
// already started typing into would throw the password away.
|
||||
void reloadIfServerRebuilt().then((reloading) => {
|
||||
if (!reloading) useSession.setState({ status: "anonymous", session: null, accountId: null, viewing: null });
|
||||
if (!reloading) useSession.setState({ status: "anonymous", session: null, accountId: null, viewing: null, sharedMail: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -220,6 +331,13 @@ export function useViewingDelegation(): Delegation | null {
|
||||
return delegationOf(session, viewing);
|
||||
}
|
||||
|
||||
/** The shared or group mailbox in view, if one is (MA-A). */
|
||||
export function useViewingShared(): SharedMailAccount | null {
|
||||
const viewing = useSession((s) => s.viewing);
|
||||
const sharedMail = useSession((s) => s.sharedMail);
|
||||
return (viewing && sharedMail.find((a) => a.id === viewing)) || null;
|
||||
}
|
||||
|
||||
export function viewingDelegation(): Delegation | null {
|
||||
const s = useSession.getState();
|
||||
return delegationOf(s.session, s.viewing);
|
||||
|
||||
@@ -206,6 +206,15 @@ export interface Settings {
|
||||
* dates nobody put there is a surprise rather than a feature.
|
||||
*/
|
||||
birthdayCalendar: boolean;
|
||||
/**
|
||||
* Save everyone written to who is not a contact yet, in an address book of
|
||||
* its own, so they are suggested on every device rather than only in the
|
||||
* browser that sent the message. On by default, as mail clients do; the
|
||||
* address book can be emptied or deleted like any other.
|
||||
*/
|
||||
collectRecipients: boolean;
|
||||
/** The address book collected recipients go to, once there is one. */
|
||||
collectedBookId: string | null;
|
||||
/**
|
||||
* Calendars subscribed to by URL. The subscription is the setting; the
|
||||
* events themselves are fetched on demand and never stored, so this follows
|
||||
@@ -361,6 +370,8 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
timeZone: null,
|
||||
labelsSidebar: true,
|
||||
birthdayCalendar: false,
|
||||
collectRecipients: true,
|
||||
collectedBookId: null,
|
||||
icalSubscriptions: [],
|
||||
listSortPreset: "newest",
|
||||
listSortLevels: [],
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Read off disk: `?raw` comes back empty for a stylesheet under the test runner.
|
||||
const css = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../app.css"), "utf8");
|
||||
|
||||
/*
|
||||
* The app rail, the collapsed sidebar and the raised surfaces are all CSS, and
|
||||
* jsdom does no layout, so these pin the rules themselves. Contrast for the
|
||||
* pill accent was measured in a browser across every palette, accent and
|
||||
* mode; the shape of that rule is pinned here.
|
||||
*/
|
||||
|
||||
const rule = (selector: string) => {
|
||||
const i = css.indexOf(selector + " {");
|
||||
expect(i, `no rule for ${selector}`).toBeGreaterThan(-1);
|
||||
return css.slice(i, css.indexOf("}", i));
|
||||
};
|
||||
|
||||
describe("app layout CSS", () => {
|
||||
it("gives the rail its own column ahead of the sidebar, collapsed or not", () => {
|
||||
expect(rule(".app-body.has-rail")).toMatch(/grid-template-columns:\s*56px var\(--sidebar-w\)/);
|
||||
expect(rule(".app-body.has-rail.collapsed")).toMatch(/grid-template-columns:\s*56px var\(--sidebar-w-collapsed\)/);
|
||||
});
|
||||
|
||||
it("hides a sidebar with no icon-only form instead of crushing it", () => {
|
||||
expect(rule(".app-body.has-rail.sidebar-hidden")).toMatch(/grid-template-columns:\s*56px 0 /);
|
||||
expect(rule(".app-body.sidebar-hidden > .sidebar")).toMatch(/visibility:\s*hidden/);
|
||||
});
|
||||
|
||||
it("hides section headings when collapsed, over the sidebar's own display rule", () => {
|
||||
// `.sidebar .nav-section { display: flex }` comes later at the same weight;
|
||||
// the collapsed rule has to outweigh it or "FOLDERS" is drawn cut off.
|
||||
expect(css).toMatch(/\.app-body\.collapsed \.sidebar \.nav-section[^{]*\{\s*display:\s*none/);
|
||||
});
|
||||
|
||||
it("darkens the pill accent in light themes and lightens it in dark ones", () => {
|
||||
expect(css).toMatch(/:root \{ --pill-accent: color-mix\(in srgb, var\(--accent\) \d+%, black\); \}/);
|
||||
expect(css).toMatch(/:root\[data-theme="dark"\] \{ --pill-accent: color-mix\(in srgb, var\(--accent\) \d+%, white\); \}/);
|
||||
});
|
||||
|
||||
it("lets a color the reader picked win over the role tint", () => {
|
||||
for (const role of ["inbox", "drafts", "sent", "archive", "junk", "trash", "scheduled"]) {
|
||||
expect(css).toContain(`.folder-icon[data-role="${role}"]:not([style*="--folder-color"]) svg`);
|
||||
}
|
||||
});
|
||||
});
|
||||
+94
-1
@@ -1257,6 +1257,10 @@ a.menu-item:hover { color: var(--fg); }
|
||||
.app.delegated { grid-template-rows: auto var(--topbar-h) 1fr; }
|
||||
.delegated-bar { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: #b91c1c; color: #fff; font-size: .9em; min-width: 0; }
|
||||
.delegated-bar strong { font-weight: 700; }
|
||||
/* MA-A: a shared or group mailbox is the reader's to be in, so the palette's own color, not the locked account's red. */
|
||||
.delegated-bar.shared { background: var(--accent); color: var(--accent-fg); }
|
||||
/* MA-8: new mail in another signed-in account, on the avatar */
|
||||
.acct-dot { position: absolute; top: 0; right: 0; width: 9px; height: 9px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 2px var(--bg); }
|
||||
.delegated-bar button { flex: none; border: 1px solid rgba(255, 255, 255, .7); background: transparent; color: #fff; border-radius: 999px; padding: 3px 12px; font: inherit; font-weight: 600; cursor: pointer; }
|
||||
.delegated-bar button:hover, .delegated-bar button:focus-visible { background: rgba(255, 255, 255, .15); }
|
||||
.topbar .brand.locked .brand-name, .topbar .brand.locked .brand-lock { color: #dc2626; }
|
||||
@@ -1316,7 +1320,7 @@ a.menu-item:hover { color: var(--fg); }
|
||||
.collapsed .compose-btn span { display: none; }
|
||||
.nav-section { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px 4px; font-size: .75em; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--fg-faint); }
|
||||
.nav-section .icon-btn { width: 24px; height: 24px; }
|
||||
.collapsed .nav-section { display: none; }
|
||||
.collapsed .nav-section, .app-body.collapsed .sidebar .nav-section { display: none; }
|
||||
.nav-item { position: relative; display: flex; align-items: center; gap: 12px; height: 36px; padding: 0 12px; border-radius: 0 999px 999px 0; margin-right: 8px; color: var(--fg); text-decoration: none; white-space: nowrap; transition: background .12s; user-select: none; font-weight: 450; }
|
||||
.nav-item:hover { background: var(--bg-hover); }
|
||||
.nav-item.unread .nav-label, .nav-item.unread .nav-count { font-weight: 700; color: var(--fg); }
|
||||
@@ -1383,6 +1387,82 @@ a.menu-item:hover { color: var(--fg); }
|
||||
.collapsed .nav-item .nav-dot { position: absolute; top: 6px; right: 6px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
|
||||
.nav-label-color { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 auto; }
|
||||
/* Outlook-style module bar at the bottom of the sidebar */
|
||||
/* App rail. A fixed column left of the sidebar on wide screens; phones keep the tab bar. */
|
||||
.app-body.has-rail { grid-template-columns: 56px var(--sidebar-w) minmax(0, 1fr); }
|
||||
.app-body.has-rail.collapsed { grid-template-columns: 56px var(--sidebar-w-collapsed) minmax(0, 1fr); }
|
||||
.app-body.has-rail > .sidebar-splitter { left: calc(56px + var(--sidebar-w) - 3px); }
|
||||
/* Only Mail's folder list has an icon-only form. Calendar, Contacts, Files, Settings and Admin
|
||||
hide their sidebar outright when collapsed; the rail still navigates and its toggle brings it back. */
|
||||
.app-body.has-rail.sidebar-hidden { grid-template-columns: 56px 0 minmax(0, 1fr); }
|
||||
.app-body.sidebar-hidden > .sidebar { visibility: hidden; padding: 0; min-width: 0; }
|
||||
.app-rail { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 0 12px; border-right: 1px solid var(--border); min-height: 0; }
|
||||
.app-rail-spacer { flex: 1; }
|
||||
.rail-toggle { background: none; border: 0; cursor: pointer; margin-top: 4px; }
|
||||
.rail-link { position: relative; display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 12px; color: var(--fg-muted); transition: background .12s, color .12s; }
|
||||
.rail-link:hover { background: var(--bg-hover); color: var(--fg); }
|
||||
.rail-link.active { background: var(--accent-soft); color: var(--accent-soft-fg); }
|
||||
.rail-badge { position: absolute; top: -3px; right: -5px; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: var(--pill-accent); color: var(--accent-fg); font-size: 10.5px; font-weight: 700; line-height: 18px; text-align: center; box-shadow: 0 0 0 2px var(--bg); }
|
||||
|
||||
/* Pill counts. Accent-filled when the folder has unread mail, neutral for totals (Drafts, Scheduled). */
|
||||
.nav-item .nav-count { display: inline-flex; align-items: center; justify-content: center; min-width: 22px; height: 20px; padding: 0 7px; border-radius: 999px; background: var(--bg-active); color: var(--fg-muted); font-size: .74em; font-weight: 650; font-variant-numeric: tabular-nums; }
|
||||
/* The accent as a fill behind small text (pills, the rail badge, Compose). The plain accent is
|
||||
a mid tone that white text does not read on in light themes, so it is darkened there and
|
||||
lightened a touch in dark ones: at least 4.5:1 in all 12 palettes x 6 accents x 2 modes,
|
||||
measured 2026-10-05 (lowest 4.83). */
|
||||
:root { --pill-accent: color-mix(in srgb, var(--accent) 70%, black); }
|
||||
:root[data-theme="dark"] { --pill-accent: color-mix(in srgb, var(--accent) 88%, white); }
|
||||
.nav-item.unread .nav-count { background: var(--pill-accent); color: var(--accent-fg); font-weight: 700; }
|
||||
.nav-item.active .nav-count, .nav-item.active.unread .nav-count { background: var(--pill-accent); color: var(--accent-fg); box-shadow: 0 1px 2px rgb(0 0 0 / .25); }
|
||||
.collapsed .nav-item .nav-count { display: none; }
|
||||
|
||||
/* Raised surfaces. A top-lit sheen, a 1px inner highlight on top and shade below, and a small drop,
|
||||
so buttons and the current selection read as pieces standing off the page. Tuned per theme lightness. */
|
||||
:root {
|
||||
--raise-sheen: linear-gradient(to bottom, rgb(255 255 255 / .30), rgb(255 255 255 / .04) 50%, rgb(0 0 0 / .06));
|
||||
--raise-sheen-strong: linear-gradient(to bottom, rgb(255 255 255 / .32), rgb(255 255 255 / .06) 50%, rgb(0 0 0 / .14));
|
||||
--raise-hi: rgb(255 255 255 / .75);
|
||||
--raise-lo: rgb(0 0 0 / .12);
|
||||
--raise-drop: 0 1px 2px rgb(0 0 0 / .14), 0 3px 8px rgb(0 0 0 / .09);
|
||||
--raise-press: inset 0 1px 2px rgb(0 0 0 / .14);
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--raise-sheen: linear-gradient(to bottom, rgb(255 255 255 / .14), rgb(255 255 255 / .02) 50%, rgb(0 0 0 / .16));
|
||||
--raise-sheen-strong: linear-gradient(to bottom, rgb(255 255 255 / .30), rgb(255 255 255 / .06) 50%, rgb(0 0 0 / .22));
|
||||
--raise-hi: rgb(255 255 255 / .16);
|
||||
--raise-lo: rgb(0 0 0 / .45);
|
||||
--raise-drop: 0 1px 2px rgb(0 0 0 / .55), 0 3px 10px rgb(0 0 0 / .32);
|
||||
--raise-press: inset 0 1px 3px rgb(0 0 0 / .45);
|
||||
}
|
||||
.btn, .compose-btn, .rail-link.active, .nav-item.active, .module-link.active, .segmented button.active {
|
||||
background-image: var(--raise-sheen);
|
||||
box-shadow: inset 0 1px 0 var(--raise-hi), inset 0 -1px 0 var(--raise-lo), var(--raise-drop);
|
||||
}
|
||||
.btn-primary, .rail-badge { background-image: var(--raise-sheen-strong); }
|
||||
.compose-btn, .compose-btn:hover { background-color: var(--pill-accent); color: var(--accent-fg); background-image: var(--raise-sheen-strong); }
|
||||
.compose-btn svg { color: inherit; }
|
||||
.compose-btn, .rail-link.active { box-shadow: inset 0 1px 0 rgb(255 255 255 / .28), inset 0 -1px 0 rgb(0 0 0 / .22), var(--raise-drop); }
|
||||
.compose-btn:hover { box-shadow: inset 0 1px 0 rgb(255 255 255 / .28), inset 0 -1px 0 rgb(0 0 0 / .22), var(--shadow-2); filter: brightness(1.05); }
|
||||
.rail-link.active, .rail-link.active:hover { background-color: var(--accent); color: var(--accent-fg); background-image: var(--raise-sheen-strong); }
|
||||
.btn-primary { box-shadow: inset 0 1px 0 rgb(255 255 255 / .22), inset 0 -1px 0 rgb(0 0 0 / .18), var(--raise-drop); }
|
||||
.btn:active, .compose-btn:active, .rail-link:active, .nav-item:active, .module-link:active, .segmented button:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: var(--raise-press);
|
||||
background-image: none;
|
||||
}
|
||||
/* The segmented control clips its children; the raise sits inside its border. */
|
||||
.segmented button.active { box-shadow: inset 0 1px 0 var(--raise-hi), inset 0 -1px 0 var(--raise-lo); }
|
||||
|
||||
/* Tinted role-folder icons. A color the reader picked for a folder still wins (style attribute, fill rule above). */
|
||||
:root { --role-inbox: #2f6fe4; --role-drafts: #8a5cd6; --role-sent: #138a6c; --role-archive: #976214; --role-junk: #b8501c; --role-trash: #c93c4b; --role-scheduled: #0f8aa6; }
|
||||
:root[data-theme="dark"] { --role-inbox: #6ea2ff; --role-drafts: #b796f5; --role-sent: #4fcfa6; --role-archive: #e5b45a; --role-junk: #f2925f; --role-trash: #f07a87; --role-scheduled: #4fc3dc; }
|
||||
.folder-row .folder-icon[data-role="inbox"]:not([style*="--folder-color"]) svg { color: var(--role-inbox); }
|
||||
.folder-row .folder-icon[data-role="drafts"]:not([style*="--folder-color"]) svg { color: var(--role-drafts); }
|
||||
.folder-row .folder-icon[data-role="sent"]:not([style*="--folder-color"]) svg { color: var(--role-sent); }
|
||||
.folder-row .folder-icon[data-role="archive"]:not([style*="--folder-color"]) svg { color: var(--role-archive); }
|
||||
.folder-row .folder-icon[data-role="junk"]:not([style*="--folder-color"]) svg { color: var(--role-junk); }
|
||||
.folder-row .folder-icon[data-role="trash"]:not([style*="--folder-color"]) svg { color: var(--role-trash); }
|
||||
.folder-row .folder-icon[data-role="scheduled"]:not([style*="--folder-color"]) svg { color: var(--role-scheduled); }
|
||||
|
||||
.module-bar { display: flex; align-items: stretch; justify-content: space-around; gap: 2px; padding: 6px 4px 4px; border-top: 1px solid var(--border); flex: 0 0 auto; }
|
||||
.module-link { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; flex: 1; min-width: 0; padding: 6px 2px; border-radius: var(--radius-sm); color: var(--fg-muted); text-decoration: none; font-size: 11px; font-weight: 500; transition: background .12s, color .12s; }
|
||||
.module-link:hover { background: var(--bg-hover); color: var(--fg); }
|
||||
@@ -1475,6 +1555,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 +1707,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,111 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { apiFetch } from "@/jmap/client";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
import { clearSignedInData, isDeviceTrusted } from "@/lib/storage";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
/**
|
||||
* inbuxa MA-B: sign a second account in beside the one in front.
|
||||
*
|
||||
* With sign-in on the mail server's own page and one mail server, there is
|
||||
* nothing to ask here: the browser goes straight to that page, which asks for
|
||||
* the account. With several servers the address comes first, to pick one; with
|
||||
* the password form, so does the password. Either way the account joins the
|
||||
* others and comes to the front, and the app reloads into it.
|
||||
*
|
||||
* It is remembered on this device exactly as the first one was (MA-7).
|
||||
*/
|
||||
export function AddAccountDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [mode, setMode] = useState<"oauth" | "oauth-address" | "password" | null>(null);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const remember = isDeviceTrusted();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setError(null);
|
||||
let live = true;
|
||||
fetch(withBase("/api/config"))
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((c) => {
|
||||
if (!live) return;
|
||||
const oauth = c?.signIn === "oauth";
|
||||
const next = oauth ? (c?.signInDirect === true ? "oauth" : "oauth-address") : "password";
|
||||
setMode(next);
|
||||
// Nothing to ask: off to the mail server's page
|
||||
if (next === "oauth") goToServer("");
|
||||
})
|
||||
.catch(() => live && setMode("password"));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// goToServer only reads `remember`, fixed for the dialog's life
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
function goToServer(address: string) {
|
||||
setBusy(true);
|
||||
// What is cached belongs to the account in front, which won't be
|
||||
clearSignedInData();
|
||||
const params = new URLSearchParams({ add: "1", ...(address ? { username: address } : {}), ...(remember ? { remember: "1" } : {}) });
|
||||
window.location.assign(withBase(`/api/auth/oauth/start?${params}`));
|
||||
}
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username.trim()) return;
|
||||
if (mode === "oauth-address") return goToServer(username.trim());
|
||||
if (!password) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username: username.trim(), password, remember, add: true }),
|
||||
});
|
||||
clearSignedInData();
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
setError((err as Error).message || t("That account couldn't be added."));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (mode === "oauth") return null;
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t("Add an account")}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-ghost" onClick={onClose} disabled={busy}>
|
||||
{t("Cancel")}
|
||||
</button>
|
||||
<button className="btn btn-primary" form="add-account" type="submit" disabled={busy || !username.trim() || (mode === "password" && !password)}>
|
||||
{busy ? t("Working…") : t("Add account")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="add-account" onSubmit={(e) => void submit(e)}>
|
||||
<p className="hint">{t("Both accounts stay signed in here; switch between them from this menu.")}</p>
|
||||
<div className="field">
|
||||
<label htmlFor="add-account-user">{t("Email address")}</label>
|
||||
<input id="add-account-user" autoComplete="username" value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
|
||||
</div>
|
||||
{mode === "password" && (
|
||||
<div className="field">
|
||||
<label htmlFor="add-account-pw">{t("Password")}</label>
|
||||
<input id="add-account-pw" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="error">{error}</p>}
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+145
-14
@@ -1,6 +1,6 @@
|
||||
import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Calendar, Check, ChevronsUpDown, FolderOpen, Globe, HelpCircle, Lock, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
|
||||
import { Calendar, Check, ChevronsUpDown, FolderOpen, Globe, HelpCircle, Lock, LogOut, Mail, Menu as MenuIcon, Moon, PanelLeftClose, PanelLeftOpen, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
|
||||
import { useSession } from "@/store/session";
|
||||
import { DEFAULT_APP_NAME, brandImage } from "@/lib/brand";
|
||||
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
|
||||
@@ -11,6 +11,8 @@ import { composeBlocked, draftFromMailto, useCompose } from "@/store/compose";
|
||||
import { delegatedAccounts } from "@/lib/delegation";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { DelegatedBar } from "./DelegatedBar";
|
||||
import { AddAccountDialog } from "./AddAccountDialog";
|
||||
import { useOtherAccountsUnread, useOtherUnread } from "@/lib/otherAccounts";
|
||||
import { Avatar, useIsMobile } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { Splitter } from "@/ui/Splitter";
|
||||
@@ -52,6 +54,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
const [location, navigate] = useLocation();
|
||||
const isMobile = useIsMobile();
|
||||
const collapsed = useSettings((s) => s.settings.sidebarCollapsed);
|
||||
const inboxUnread = useMail((s) => Object.values(s.mailboxes).find((m) => m.role === "inbox")?.unreadEmails ?? 0);
|
||||
const sidebarWidth = useSettings((s) => s.settings.sidebarWidth);
|
||||
const update = useSettings((s) => s.update);
|
||||
/*
|
||||
@@ -72,6 +75,39 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
const pushState = useSession((s) => s.pushState);
|
||||
const session = useSession((s) => s.session);
|
||||
const logout = useSession((s) => s.logout);
|
||||
// inbuxa MA-B: the other accounts signed in here, and adding one
|
||||
const signedIn = useSession((s) => s.signedIn);
|
||||
const canAddAccount = useSession((s) => s.canAddAccount);
|
||||
const [addingAccount, setAddingAccount] = useState(false);
|
||||
// inbuxa MA-8: new mail in the accounts not in front
|
||||
useOtherAccountsUnread();
|
||||
const otherUnread = useOtherUnread((s) => s.unread);
|
||||
const anyOtherUnread = Object.values(otherUnread).some((n) => n > 0);
|
||||
const bringForward = (sessionId: string) => {
|
||||
acctMenu.close();
|
||||
// A message being written belongs to the account it was started in
|
||||
if (useCompose.getState().drafts.length) {
|
||||
toast.show(t("Send or close the message you're writing first."));
|
||||
return;
|
||||
}
|
||||
void useSession.getState().switchTo(sessionId);
|
||||
};
|
||||
// A refused add comes back on the address (see the server's /auth/callback)
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const why = params.get("account_error");
|
||||
if (!why) return;
|
||||
toast.show(
|
||||
why === "add_full"
|
||||
? t("You can't add more accounts here.")
|
||||
: why === "add_other_server"
|
||||
? t("That account is on another mail server. Only accounts on this server can be added.")
|
||||
: t("Your organization doesn't allow adding other accounts here."),
|
||||
);
|
||||
params.delete("account_error");
|
||||
const rest = params.toString();
|
||||
window.history.replaceState(null, "", window.location.pathname + (rest ? `?${rest}` : ""));
|
||||
}, []);
|
||||
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
|
||||
const acctMenu = useMenu();
|
||||
const administers = hasAdministration(usePermissions());
|
||||
@@ -149,6 +185,9 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
*/
|
||||
const viewing = useSession((s) => s.viewing);
|
||||
const delegated = delegatedAccounts(session);
|
||||
// MA-A: shared and group mailboxes are offered in the same list, after locked accounts
|
||||
const sharedMail = useSession((s) => s.sharedMail);
|
||||
const lockedInView = viewing !== null && delegated.some((a) => a.id === viewing);
|
||||
const switchTo = (id: string | null) => {
|
||||
acctMenu.close();
|
||||
if (id === viewing) return;
|
||||
@@ -166,12 +205,15 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<div className={`app ${viewing ? "delegated" : ""}`}>
|
||||
<DelegatedBar />
|
||||
<header className="topbar">
|
||||
<button className="icon-btn" aria-label={t("Menu")} onClick={() => (isMobile ? setDrawer((d) => !d) : update({ sidebarCollapsed: !collapsed }))}>
|
||||
<MenuIcon size={22} />
|
||||
</button>
|
||||
<Link href="/mail" className={`brand ${viewing ? "locked" : ""}`}>
|
||||
{/* On a wide screen the folder list is toggled from the bottom of the rail; phones keep the menu button for the drawer. */}
|
||||
{isMobile && (
|
||||
<button className="icon-btn" aria-label={t("Menu")} onClick={() => setDrawer((d) => !d)}>
|
||||
<MenuIcon size={22} />
|
||||
</button>
|
||||
)}
|
||||
<Link href="/mail" className={`brand ${lockedInView ? "locked" : ""}`}>
|
||||
<img src={brandImage(appName === DEFAULT_APP_NAME ? "/img/inbuxa-mark.png" : "/img/logo.png")} alt="" />
|
||||
{viewing && <Lock size={18} className="brand-lock" aria-label={t("Locked account")} />}
|
||||
{lockedInView && <Lock size={18} className="brand-lock" aria-label={t("Locked account")} />}
|
||||
{/* A product name, not a word: translated it is a different product.
|
||||
Read from the session rather than written here, so a deployment
|
||||
that set APP_NAME is called what it calls itself -- the document
|
||||
@@ -193,11 +235,21 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<HelpCircle size={21} />
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
<Link href="/settings" className={`icon-btn ${section === "settings" ? "active" : ""}`} aria-label={t("Settings")} title={t("Settings")}>
|
||||
<Settings size={21} />
|
||||
</Link>
|
||||
<button className="icon-btn" style={{ width: "auto", padding: "0 2px", borderRadius: 999 }} onClick={acctMenu.open} aria-label={t("Account")}>
|
||||
{/* On a wide screen the rail carries Settings; phones have no rail, so they keep this one. */}
|
||||
{isMobile && (
|
||||
<Link href="/settings" className={`icon-btn ${section === "settings" ? "active" : ""}`} aria-label={t("Settings")} title={t("Settings")}>
|
||||
<Settings size={21} />
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
className="icon-btn"
|
||||
style={{ width: "auto", padding: "0 2px", borderRadius: 999, position: "relative" }}
|
||||
onClick={acctMenu.open}
|
||||
aria-label={anyOtherUnread ? t("Account: new mail in another account") : t("Account")}
|
||||
>
|
||||
<Avatar who={{ name: session?.username, email: session?.username }} size="sm" />
|
||||
{/* inbuxa MA-8: another signed-in account has unread mail */}
|
||||
{anyOtherUnread && <span className="acct-dot" aria-hidden />}
|
||||
</button>
|
||||
<Popover anchor={acctMenu.anchor} onClose={acctMenu.close} align="end" width={280}>
|
||||
<div style={{ padding: "10px 10px 6px", display: "flex", gap: 10, alignItems: "center" }}>
|
||||
@@ -210,7 +262,43 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
</div>
|
||||
</div>
|
||||
<MenuSep />
|
||||
{delegated.length > 0 && (
|
||||
{/* inbuxa MA-B: each account signed in here, the one in front ticked */}
|
||||
{(signedIn.length > 1 || canAddAccount) && (
|
||||
<>
|
||||
{signedIn.length > 1 && <div className="hint" style={{ padding: "4px 10px" }}>{t("Accounts")}</div>}
|
||||
{signedIn.length > 1 &&
|
||||
signedIn.map((account) => (
|
||||
<MenuItem
|
||||
key={account.id}
|
||||
icon={account.front ? <Check size={16} /> : <Mail size={16} />}
|
||||
label={
|
||||
<span className="row" style={{ gap: 8, alignItems: "center" }}>
|
||||
<span className="notranslate grow truncate" translate="no">{account.username}</span>
|
||||
{!account.front && (otherUnread[account.id] ?? 0) > 0 && (
|
||||
<span className="nav-count" aria-label={t("Unread in the Inbox: {count}", { count: otherUnread[account.id]! })}>
|
||||
{otherUnread[account.id]! > 9999 ? "9999+" : otherUnread[account.id]}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
active={account.front}
|
||||
onClick={() => (account.front ? acctMenu.close() : bringForward(account.id))}
|
||||
/>
|
||||
))}
|
||||
{canAddAccount && (
|
||||
<MenuItem
|
||||
icon={<Plus size={16} />}
|
||||
label={t("Add account")}
|
||||
onClick={() => {
|
||||
acctMenu.close();
|
||||
setAddingAccount(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MenuSep />
|
||||
</>
|
||||
)}
|
||||
{delegated.length + sharedMail.length > 0 && (
|
||||
<>
|
||||
<div className="hint" style={{ padding: "4px 10px" }}>{t("Mail to show")}</div>
|
||||
<MenuItem
|
||||
@@ -228,6 +316,15 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
onClick={() => switchTo(account.id)}
|
||||
/>
|
||||
))}
|
||||
{sharedMail.map((account) => (
|
||||
<MenuItem
|
||||
key={account.id}
|
||||
icon={viewing === account.id ? <Check size={16} /> : <Users size={16} />}
|
||||
label={<span className="notranslate" translate="no">{account.name}</span>}
|
||||
active={viewing === account.id}
|
||||
onClick={() => switchTo(account.id)}
|
||||
/>
|
||||
))}
|
||||
<MenuSep />
|
||||
</>
|
||||
)}
|
||||
@@ -258,15 +355,39 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
)}
|
||||
<MenuItem icon={<RefreshCw size={16} />} label={t("Refresh")} onClick={() => window.location.reload()} />
|
||||
<MenuItem icon={<LogOut size={16} />} label={t("Sign out")} onClick={() => void logout()} />
|
||||
{signedIn.length > 1 && (
|
||||
<MenuItem icon={<LogOut size={16} />} label={t("Sign out of all accounts")} onClick={() => void useSession.getState().logoutAll()} />
|
||||
)}
|
||||
</Popover>
|
||||
<AddAccountDialog open={addingAccount} onClose={() => setAddingAccount(false)} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className={`app-body ${collapsed && !isMobile ? "collapsed" : ""} ${liveSidebarWidth != null ? "resizing" : ""}`}
|
||||
className={`app-body ${!isMobile ? "has-rail" : ""} ${collapsed && !isMobile ? "collapsed" : ""} ${collapsed && !isMobile && section !== "mail" && section !== "search" ? "sidebar-hidden" : ""} ${liveSidebarWidth != null ? "resizing" : ""}`}
|
||||
style={shownSidebarWidth != null && !isMobile ? ({ "--sidebar-w": `${shownSidebarWidth}px` } as React.CSSProperties) : undefined}
|
||||
>
|
||||
<div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} />
|
||||
{!isMobile && (
|
||||
<nav className="app-rail" aria-label={t("Go to")}>
|
||||
<RailLink href="/mail" icon={<Mail size={20} />} label={t("Mail")} active={section === "mail" || section === "search"} badge={inboxUnread} />
|
||||
<RailLink href="/calendar" icon={<Calendar size={20} />} label={t("Calendar")} active={section === "calendar"} />
|
||||
<RailLink href="/contacts" icon={<Users size={20} />} label={t("Contacts")} active={section === "contacts"} />
|
||||
<RailLink href="/files" icon={<FolderOpen size={20} />} label={t("Files")} active={section === "files"} />
|
||||
<span className="app-rail-spacer" />
|
||||
<RailLink href="/settings" icon={<Settings size={20} />} label={t("Settings")} active={section === "settings"} />
|
||||
<button
|
||||
type="button"
|
||||
className="rail-link rail-toggle"
|
||||
aria-label={collapsed ? t("Show folder list") : t("Hide folder list")}
|
||||
title={collapsed ? t("Show folder list") : t("Hide folder list")}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => update({ sidebarCollapsed: !collapsed })}
|
||||
>
|
||||
{collapsed ? <PanelLeftOpen size={20} /> : <PanelLeftClose size={20} />}
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
<aside ref={sidebarRef} className={`sidebar ${drawer ? "open" : ""}`}>
|
||||
{/*
|
||||
The way back out.
|
||||
@@ -316,12 +437,12 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
{section === "admin" && <AdminNav />}
|
||||
</div>
|
||||
{(section === "mail" || section === "search") && <QuotaBar />}
|
||||
<nav className="module-bar" aria-label={t("Go to")}>
|
||||
{isMobile && <nav className="module-bar" aria-label={t("Go to")}>
|
||||
<ModuleLink href="/mail" icon={<Mail size={20} />} label={t("Mail")} active={section === "mail" || section === "search"} />
|
||||
<ModuleLink href="/calendar" icon={<Calendar size={20} />} label={t("Calendar")} active={section === "calendar"} />
|
||||
<ModuleLink href="/contacts" icon={<Users size={20} />} label={t("Contacts")} active={section === "contacts"} />
|
||||
<ModuleLink href="/files" icon={<FolderOpen size={20} />} label={t("Files")} active={section === "files"} />
|
||||
</nav>
|
||||
</nav>}
|
||||
</aside>
|
||||
{/* Not on a phone, where the sidebar is a drawer over the page, and not
|
||||
while collapsed to icons, where there is no width to choose. */}
|
||||
@@ -406,6 +527,16 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The app rail down the left edge, which replaces the module bar on a wide screen. */
|
||||
function RailLink({ href, icon, label, active, badge = 0 }: { href: string; icon: ReactNode; label: string; active: boolean; badge?: number }) {
|
||||
return (
|
||||
<Link href={href} className={`rail-link ${active ? "active" : ""}`} title={label} aria-label={label} aria-current={active ? "page" : undefined}>
|
||||
{icon}
|
||||
{badge > 0 && <span className="rail-badge">{badge > 999 ? "999+" : badge}</span>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** Outlook-style module switcher at the bottom of the folder pane. */
|
||||
function ModuleLink({ href, icon, label, active }: { href: string; icon: ReactNode; label: string; active: boolean }) {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Lock } from "lucide-react";
|
||||
import { Lock, Users } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useSession, useViewingDelegation } from "@/store/session";
|
||||
import { useSession, useViewingDelegation, useViewingShared } from "@/store/session";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { DelegationAccess } from "@/lib/delegation";
|
||||
|
||||
@@ -25,7 +25,29 @@ export function DelegatedBar() {
|
||||
const viewing = useSession((s) => s.viewing);
|
||||
const name = useSession((s) => (s.viewing ? s.session?.accounts[s.viewing]?.name : undefined));
|
||||
const delegation = useViewingDelegation();
|
||||
const shared = useViewingShared();
|
||||
const [, navigate] = useLocation();
|
||||
const back = () => {
|
||||
useSession.getState().view(null);
|
||||
navigate("/mail");
|
||||
};
|
||||
// MA-A: a shared or group mailbox in view says whose it is, with the same way back
|
||||
if (viewing && shared) {
|
||||
return (
|
||||
<div className="delegated-bar shared" role="status">
|
||||
<Users size={15} aria-hidden />
|
||||
<span className="grow truncate">
|
||||
{t("Shared mailbox:")} <strong className="notranslate" translate="no">{shared.name}</strong>
|
||||
{/* MA-S: one an administrator assigned says at what level */}
|
||||
{delegation ? ` · ${accessText(delegation.access)}` : ""}
|
||||
{delegation?.sendAs ? ` · ${t("You can send as this account")}` : ""}
|
||||
</span>
|
||||
<button type="button" onClick={back}>
|
||||
{t("Back to my mail")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!viewing || !delegation) return null;
|
||||
return (
|
||||
<div className="delegated-bar" role="status">
|
||||
@@ -36,13 +58,7 @@ export function DelegatedBar() {
|
||||
{accessText(delegation.access)}
|
||||
{delegation.sendAs ? ` · ${t("You can send as this account")}` : ""}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
useSession.getState().view(null);
|
||||
navigate("/mail");
|
||||
}}
|
||||
>
|
||||
<button type="button" onClick={back}>
|
||||
{t("Back to my mail")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -248,7 +248,7 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
already knows the name they are half-way through typing. */}
|
||||
<button type="button" className="link-btn" onClick={() => setAddressBookOpen(true)} title={translate("Choose from address books")}>{translate("To")}</button>
|
||||
</label>
|
||||
<RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder={translate("Recipients")} autoFocus={initialFocus === "to"} />
|
||||
<RecipientInput id={`${key}-to`} value={d.to} exclude={[...d.cc, ...d.bcc]} onChange={(to) => patch({ to })} placeholder={translate("Recipients")} autoFocus={initialFocus === "to"} />
|
||||
<span className="field-extra">
|
||||
{/* Beside Cc and Bcc, because that is where someone looks when
|
||||
they are thinking about who the message goes to. The label
|
||||
@@ -268,13 +268,13 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
{d.showCc && (
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-cc`}>{translate("Cc")}</label>
|
||||
<RecipientInput id={`${key}-cc`} value={d.cc} onChange={(cc) => patch({ cc })} />
|
||||
<RecipientInput id={`${key}-cc`} value={d.cc} exclude={[...d.to, ...d.bcc]} onChange={(cc) => patch({ cc })} />
|
||||
</div>
|
||||
)}
|
||||
{d.showBcc && (
|
||||
<div className="composer-field">
|
||||
<label htmlFor={`${key}-bcc`}>{translate("Bcc")}</label>
|
||||
<RecipientInput id={`${key}-bcc`} value={d.bcc} onChange={(bcc) => patch({ bcc })} />
|
||||
<RecipientInput id={`${key}-bcc`} value={d.bcc} exclude={[...d.to, ...d.cc]} onChange={(bcc) => patch({ bcc })} />
|
||||
</div>
|
||||
)}
|
||||
<div className="composer-field">
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
|
||||
/*
|
||||
* Resizing an image in the composer (Gitea issue #26).
|
||||
*
|
||||
* An inserted image went in at its own size, capped at the editor's width,
|
||||
* and nothing could change it. A screenshot pasted from a large monitor
|
||||
* filled the message. Click an image and this puts a frame round it, with a
|
||||
* handle in the corner to drag it to any width, and three fixed sizes plus
|
||||
* Original size above it.
|
||||
*
|
||||
* The frame is drawn over the editor rather than inside it, so none of it
|
||||
* ends up in the message. The only thing that changes in the message is the
|
||||
* image's own width: as a `width` attribute, which Outlook needs because it
|
||||
* ignores CSS on images, and as an inline style, which everything else
|
||||
* reads. `max-width:100%` stays, so a large image still shrinks to fit a
|
||||
* phone on the receiving end.
|
||||
*/
|
||||
|
||||
/** Fixed widths, in CSS pixels, as offered in the report. */
|
||||
export const IMAGE_SIZE_PRESETS: ReadonlyArray<{ label: string; width: number }> = [
|
||||
{ label: "Small", width: 300 },
|
||||
{ label: "Medium", width: 600 },
|
||||
{ label: "Large", width: 900 },
|
||||
];
|
||||
|
||||
const MIN_WIDTH = 24;
|
||||
|
||||
/** Set an image's width, or clear it with `null` to go back to its own size. */
|
||||
export function setImageWidth(img: HTMLImageElement, width: number | null): void {
|
||||
// A height would fight the new width and stretch the image.
|
||||
img.removeAttribute("height");
|
||||
img.style.height = "auto";
|
||||
img.style.maxWidth = "100%";
|
||||
if (width === null) {
|
||||
img.removeAttribute("width");
|
||||
img.style.width = "";
|
||||
return;
|
||||
}
|
||||
const w = Math.max(MIN_WIDTH, Math.round(width));
|
||||
img.setAttribute("width", String(w));
|
||||
img.style.width = `${w}px`;
|
||||
}
|
||||
|
||||
/** The width the image has been given, or null when it is at its own size. */
|
||||
export function imageWidth(img: HTMLImageElement): number | null {
|
||||
const w = Number.parseInt(img.getAttribute("width") ?? "", 10);
|
||||
return Number.isFinite(w) && w > 0 ? w : null;
|
||||
}
|
||||
|
||||
interface Box {
|
||||
/** Where the editor area sits inside the composer, which is what the layer is positioned against. */
|
||||
areaTop: number;
|
||||
areaLeft: number;
|
||||
areaWidth: number;
|
||||
areaHeight: number;
|
||||
/** The image, relative to the editor area's visible box. */
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** The editable area. Its parent must be positioned, as `.composer-editor` is. */
|
||||
area: HTMLDivElement;
|
||||
img: HTMLImageElement;
|
||||
/** Called once a change is finished, to save the draft. */
|
||||
onChange: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ImageResizer({ area, img, onChange, onClose }: Props) {
|
||||
const [box, setBox] = useState<Box | null>(null);
|
||||
const [width, setWidth] = useState<number | null>(() => imageWidth(img));
|
||||
const layerRef = useRef<HTMLDivElement>(null);
|
||||
const drag = useRef<{ x: number; width: number; max: number } | null>(null);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const a = area.getBoundingClientRect();
|
||||
const r = img.getBoundingClientRect();
|
||||
setBox({
|
||||
areaTop: area.offsetTop, areaLeft: area.offsetLeft, areaWidth: a.width, areaHeight: a.height,
|
||||
top: r.top - a.top, left: r.left - a.left, width: r.width, height: r.height,
|
||||
});
|
||||
}, [area, img]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(img);
|
||||
ro.observe(area);
|
||||
area.addEventListener("scroll", measure);
|
||||
window.addEventListener("resize", measure);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
area.removeEventListener("scroll", measure);
|
||||
window.removeEventListener("resize", measure);
|
||||
};
|
||||
}, [area, img, measure]);
|
||||
|
||||
// Anything but the image or the frame lets it go, and so does Escape.
|
||||
useEffect(() => {
|
||||
const onDown = (e: PointerEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (target === img || layerRef.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("pointerdown", onDown, true);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onDown, true);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [img, onClose]);
|
||||
|
||||
const apply = (w: number | null) => {
|
||||
setImageWidth(img, w);
|
||||
setWidth(imageWidth(img));
|
||||
measure();
|
||||
onChange();
|
||||
};
|
||||
|
||||
const onHandleDown = (e: ReactPointerEvent<HTMLSpanElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
const style = getComputedStyle(area);
|
||||
const max = area.clientWidth - Number.parseFloat(style.paddingLeft) - Number.parseFloat(style.paddingRight);
|
||||
drag.current = { x: e.clientX, width: img.getBoundingClientRect().width, max };
|
||||
};
|
||||
const onHandleMove = (e: ReactPointerEvent<HTMLSpanElement>) => {
|
||||
const d = drag.current;
|
||||
if (!d) return;
|
||||
const w = Math.min(d.max, Math.max(MIN_WIDTH, d.width + (e.clientX - d.x)));
|
||||
setImageWidth(img, w);
|
||||
measure();
|
||||
};
|
||||
const onHandleUp = (e: ReactPointerEvent<HTMLSpanElement>) => {
|
||||
if (!drag.current) return;
|
||||
drag.current = null;
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
setWidth(imageWidth(img));
|
||||
onChange();
|
||||
};
|
||||
|
||||
if (!box) return null;
|
||||
// The size bar sits above the image, or inside its top edge when the image
|
||||
// starts at the top of what is visible.
|
||||
const barTop = Math.max(4, box.top - 34);
|
||||
return (
|
||||
<div
|
||||
ref={layerRef}
|
||||
className="img-resize-layer"
|
||||
style={{ top: box.areaTop, left: box.areaLeft, width: box.areaWidth, height: box.areaHeight }}
|
||||
>
|
||||
<div className="img-resize-frame" style={{ top: box.top, left: box.left, width: box.width, height: box.height }}>
|
||||
<span
|
||||
className="img-resize-handle"
|
||||
role="slider"
|
||||
aria-label={translate("Drag to resize")}
|
||||
aria-valuenow={Math.round(box.width)}
|
||||
aria-valuemin={MIN_WIDTH}
|
||||
title={translate("Drag to resize")}
|
||||
onPointerDown={onHandleDown}
|
||||
onPointerMove={onHandleMove}
|
||||
onPointerUp={onHandleUp}
|
||||
onPointerCancel={onHandleUp}
|
||||
/>
|
||||
</div>
|
||||
<div className="img-resize-bar" role="toolbar" aria-label={translate("Image size")} style={{ top: barTop, left: Math.max(4, box.left) }}>
|
||||
{IMAGE_SIZE_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.width}
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
aria-pressed={width === p.width}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => apply(p.width)}
|
||||
>
|
||||
{translate(p.label)}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="btn btn-sm" aria-pressed={width === null} onMouseDown={(e) => e.preventDefault()} onClick={() => apply(null)}>
|
||||
{translate("Original size")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,9 +12,11 @@ interface Props {
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
id?: string;
|
||||
/** Addresses already in the message's other recipient fields, not to be suggested again. */
|
||||
exclude?: EmailAddress[];
|
||||
}
|
||||
|
||||
export function RecipientInput({ value, onChange, placeholder, autoFocus, id }: Props) {
|
||||
export function RecipientInput({ value, onChange, placeholder, autoFocus, id, exclude }: Props) {
|
||||
const [text, setText] = useState("");
|
||||
const [sugg, setSugg] = useState<Suggestion[]>([]);
|
||||
const [active, setActive] = useState(0);
|
||||
@@ -34,7 +36,7 @@ export function RecipientInput({ value, onChange, placeholder, autoFocus, id }:
|
||||
const t = window.setTimeout(() => {
|
||||
void suggest(q).then((list) => {
|
||||
if (id !== reqId.current) return;
|
||||
const existing = new Set(value.map((v) => v.email.toLowerCase()));
|
||||
const existing = new Set([...value, ...(exclude ?? [])].map((v) => v.email.toLowerCase()));
|
||||
const filtered = list.filter((s) => !existing.has(s.email.toLowerCase()));
|
||||
setSugg(filtered);
|
||||
setActive(0);
|
||||
@@ -42,7 +44,7 @@ export function RecipientInput({ value, onChange, placeholder, autoFocus, id }:
|
||||
});
|
||||
}, 120);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [text, suggest, value]);
|
||||
}, [text, suggest, value, exclude]);
|
||||
|
||||
const commit = (raw?: string) => {
|
||||
const s = (raw ?? text).trim().replace(/[,;]+$/, "");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AlignCenter, AlignLeft, AlignRight, Bold, Code, Eraser, Image as ImageI
|
||||
import { sanitizeEditorHtml } from "@/lib/text/html";
|
||||
import { Popover, useMenu } from "@/ui/popover";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
import { ImageResizer } from "./ImageResizer";
|
||||
|
||||
export interface RichEditorHandle {
|
||||
focus(): void;
|
||||
@@ -24,6 +25,15 @@ interface Props {
|
||||
imageUpload?: (file: File) => Promise<string>;
|
||||
}
|
||||
|
||||
/** Pasted or dropped images larger than this go in as attachments, not inline. */
|
||||
export const INLINE_IMAGE_MAX = 10 * 1024 * 1024;
|
||||
|
||||
/** A single http(s) or mailto address, nothing else: what may become a link over a selection. */
|
||||
export function isLinkToPaste(text: string): boolean {
|
||||
if (!text || /\s/.test(text)) return false;
|
||||
return /^(https?:\/\/[^\s]+|mailto:[^\s@]+@[^\s@]+)$/i.test(text);
|
||||
}
|
||||
|
||||
const EMOJI = "😀 😃 😄 😁 😆 😅 😂 🤣 🙂 😉 😊 😇 🥰 😍 😘 😋 😜 🤪 🤗 🤔 🤫 🤐 😐 😑 😶 😏 😒 🙄 😬 😌 😔 😪 😴 😷 🤒 🤕 🤢 🤮 🥵 🥶 🥴 😵 🤯 🤠 🥳 😎 🤓 🧐 😕 😟 🙁 😮 😯 😲 😳 🥺 😦 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 😓 😩 😫 🥱 😤 😡 😠 🤬 👍 👎 👌 ✌️ 🤞 🤟 🤘 🤙 👈 👉 👆 👇 ☝️ 👋 🤚 🖐️ ✋ 🖖 👏 🙌 👐 🤲 🤝 🙏 💪 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ❣️ 💕 💯 💥 🔥 ✨ 🎉 🎊 🎈 🎁 🏆 ⭐ 🌟 ☀️ 🌙 ⚡ ☕ 🍕 🍺 🚀 ✈️ 🏠 💼 📅 📎 📌 ✅ ❌ ⚠️ ❓ ❗ 💡 🔔 📧 🙈 🙉 🙊 🐱 🐶 🦊 🐼".split(" ");
|
||||
const COLORS = ["#000000", "#434343", "#666666", "#999999", "#b7b7b7", "#cccccc", "#d9d9d9", "#ffffff", "#980000", "#ff0000", "#ff9900", "#ffff00", "#00ff00", "#00ffff", "#4a86e8", "#0000ff", "#9900ff", "#ff00ff", "#e6b8af", "#f4cccc", "#fce5cd", "#fff2cc", "#d9ead3", "#d0e0e3", "#c9daf8", "#cfe2f3", "#d9d2e9", "#ead1dc", "#cc4125", "#e06666", "#f6b26b", "#ffd966", "#93c47d", "#76a5af", "#6d9eeb", "#6fa8dc", "#8e7cc3", "#c27ba0", "#a61c00", "#cc0000", "#e69138", "#f1c232", "#6aa84f", "#45818e", "#3c78d8", "#3d85c6", "#674ea7", "#a64d79"];
|
||||
|
||||
@@ -37,6 +47,8 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
||||
const linkMenu = useMenu();
|
||||
const [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 +57,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 +85,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]);
|
||||
@@ -127,10 +142,25 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
||||
const f = imgItem.getAsFile();
|
||||
if (f) {
|
||||
e.preventDefault();
|
||||
insertImageFile(f);
|
||||
// A large image is a file to send, not a picture in the text: it goes
|
||||
// in as an attachment, where an inline copy would swell every reply.
|
||||
if (f.size > INLINE_IMAGE_MAX && onFiles) onFiles([f]);
|
||||
else insertImageFile(f);
|
||||
return;
|
||||
}
|
||||
}
|
||||
/*
|
||||
* A link pasted over selected words makes those words the link, the way
|
||||
* most editors do, instead of replacing them with the address.
|
||||
*/
|
||||
const pastedText = e.clipboardData.getData("text/plain").trim();
|
||||
const sel = window.getSelection();
|
||||
if (isLinkToPaste(pastedText) && sel && !sel.isCollapsed && elRef.current?.contains(sel.anchorNode)) {
|
||||
e.preventDefault();
|
||||
document.execCommand("createLink", false, pastedText);
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
const htmlData = e.clipboardData.getData("text/html");
|
||||
if (htmlData) {
|
||||
e.preventDefault();
|
||||
@@ -169,8 +199,9 @@ export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEdito
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (!files.length) return;
|
||||
e.preventDefault();
|
||||
const images = files.filter((f) => f.type.startsWith("image/"));
|
||||
const others = files.filter((f) => !f.type.startsWith("image/"));
|
||||
const inline = (f: File) => f.type.startsWith("image/") && !(f.size > INLINE_IMAGE_MAX && onFiles);
|
||||
const images = files.filter(inline);
|
||||
const others = files.filter((f) => !inline(f));
|
||||
images.forEach(insertImageFile);
|
||||
if (others.length) onFiles?.(others);
|
||||
};
|
||||
@@ -205,6 +236,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 +260,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,102 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { INLINE_IMAGE_MAX, RichEditor, isLinkToPaste } from "../RichEditor";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver;
|
||||
|
||||
/*
|
||||
* Two things pasting into a message now does: a link pasted over selected
|
||||
* words turns those words into the link, and an image too large to belong
|
||||
* in the text goes in as an attachment. jsdom does no editing, so these check
|
||||
* the command and the callback the editor reaches for.
|
||||
*/
|
||||
|
||||
describe("what counts as a link to paste", () => {
|
||||
it("takes one web or mailto address", () => {
|
||||
expect(isLinkToPaste("https://example.com/a?b=1")).toBe(true);
|
||||
expect(isLinkToPaste("http://example.com")).toBe(true);
|
||||
expect(isLinkToPaste("mailto:[email protected]")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves ordinary text and anything with spaces alone", () => {
|
||||
expect(isLinkToPaste("see https://example.com")).toBe(false);
|
||||
expect(isLinkToPaste("example.com")).toBe(false);
|
||||
expect(isLinkToPaste("javascript:alert(1)")).toBe(false);
|
||||
expect(isLinkToPaste("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pasting into the editor", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
let onFiles: ReturnType<typeof vi.fn<(files: File[]) => void>>;
|
||||
let exec: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
onFiles = vi.fn<(files: File[]) => void>();
|
||||
exec = vi.fn(() => true);
|
||||
(document as unknown as { execCommand: unknown }).execCommand = exec;
|
||||
await act(async () => {
|
||||
root.render(<RichEditor html="<p>read the docs here</p>" onChange={() => {}} onFiles={onFiles} showToolbar={false} />);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
const editor = () => host.querySelector<HTMLElement>('[contenteditable="true"]')!;
|
||||
|
||||
const paste = async (data: { text?: string; html?: string; files?: File[] }) => {
|
||||
const items = (data.files ?? []).map((f) => ({ type: f.type, kind: "file", getAsFile: () => f }));
|
||||
const ev = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(ev, "clipboardData", {
|
||||
value: { items, getData: (t: string) => (t === "text/plain" ? data.text ?? "" : t === "text/html" ? data.html ?? "" : "") },
|
||||
});
|
||||
await act(async () => editor().dispatchEvent(ev));
|
||||
return ev;
|
||||
};
|
||||
|
||||
const select = (word: string) => {
|
||||
const text = editor().querySelector("p")!.firstChild!;
|
||||
const i = text.textContent!.indexOf(word);
|
||||
const r = document.createRange();
|
||||
r.setStart(text, i);
|
||||
r.setEnd(text, i + word.length);
|
||||
const sel = window.getSelection()!;
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(r);
|
||||
};
|
||||
|
||||
it("turns selected words into the pasted link", async () => {
|
||||
select("docs");
|
||||
const ev = await paste({ text: "https://example.com/docs" });
|
||||
expect(ev.defaultPrevented).toBe(true);
|
||||
expect(exec).toHaveBeenCalledWith("createLink", false, "https://example.com/docs");
|
||||
});
|
||||
|
||||
it("pastes a link as text when nothing is selected", async () => {
|
||||
window.getSelection()!.removeAllRanges();
|
||||
await paste({ text: "https://example.com/docs" });
|
||||
expect(exec).not.toHaveBeenCalledWith("createLink", expect.anything(), expect.anything());
|
||||
});
|
||||
|
||||
it("attaches a pasted image too large to sit in the text", async () => {
|
||||
const big = new File([new Uint8Array(8)], "huge.png", { type: "image/png" });
|
||||
Object.defineProperty(big, "size", { value: INLINE_IMAGE_MAX + 1 });
|
||||
await paste({ files: [big] });
|
||||
expect(onFiles).toHaveBeenCalledWith([big]);
|
||||
});
|
||||
|
||||
it("keeps a normal-sized pasted image in the text", async () => {
|
||||
const small = new File([new Uint8Array(8)], "shot.png", { type: "image/png" });
|
||||
await paste({ files: [small] });
|
||||
expect(onFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RichEditor } from "../RichEditor";
|
||||
import { imageWidth, setImageWidth } from "../ImageResizer";
|
||||
import { sanitizeEditorHtml } from "@/lib/text/html";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver;
|
||||
|
||||
/*
|
||||
* An image in the composer could not be resized at all (Gitea issue #26).
|
||||
* jsdom does no layout, so dragging the handle is checked in a browser; these
|
||||
* pin what a size does to the message, and that the frame never ends up in it.
|
||||
*/
|
||||
|
||||
describe("setting an image's width", () => {
|
||||
it("writes both the attribute Outlook reads and the style everything else reads", () => {
|
||||
const img = document.createElement("img");
|
||||
img.setAttribute("height", "480");
|
||||
setImageWidth(img, 300);
|
||||
expect(img.getAttribute("width")).toBe("300");
|
||||
expect(img.style.width).toBe("300px");
|
||||
// A leftover height would stretch it out of shape.
|
||||
expect(img.hasAttribute("height")).toBe(false);
|
||||
expect(img.style.height).toBe("auto");
|
||||
// Still shrinks to fit a narrow screen on the receiving end.
|
||||
expect(img.style.maxWidth).toBe("100%");
|
||||
expect(imageWidth(img)).toBe(300);
|
||||
});
|
||||
|
||||
it("goes back to the image's own size", () => {
|
||||
const img = document.createElement("img");
|
||||
setImageWidth(img, 600);
|
||||
setImageWidth(img, null);
|
||||
expect(img.hasAttribute("width")).toBe(false);
|
||||
expect(img.style.width).toBe("");
|
||||
expect(imageWidth(img)).toBeNull();
|
||||
});
|
||||
|
||||
it("survives the sanitizer a draft goes through", () => {
|
||||
const img = document.createElement("img");
|
||||
img.src = "https://example.com/a.png";
|
||||
setImageWidth(img, 450);
|
||||
const out = sanitizeEditorHtml(img.outerHTML);
|
||||
expect(out).toContain('width="450"');
|
||||
expect(out).toContain("width: 450px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resizing an image in the editor", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
let onChange: ReturnType<typeof vi.fn<(html: string) => void>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
onChange = vi.fn<(html: string) => void>();
|
||||
await act(async () => {
|
||||
root.render(<RichEditor html='<p>Hi</p><img src="data:image/png;base64,AA==" alt="shot" style="max-width:100%">' onChange={onChange} showToolbar={false} />);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
const editor = () => host.querySelector<HTMLElement>('[contenteditable="true"]')!;
|
||||
const img = () => editor().querySelector("img")!;
|
||||
const button = (label: string) => [...host.querySelectorAll<HTMLButtonElement>(".img-resize-bar button")].find((b) => b.textContent === label);
|
||||
|
||||
it("offers sizes when the image is clicked, and applies one", async () => {
|
||||
expect(host.querySelector(".img-resize-bar")).toBeNull();
|
||||
await act(async () => img().click());
|
||||
expect(button("Medium")).toBeTruthy();
|
||||
await act(async () => button("Medium")!.click());
|
||||
expect(imageWidth(img())).toBe(600);
|
||||
expect(button("Medium")!.getAttribute("aria-pressed")).toBe("true");
|
||||
// The draft is saved with the new width in it.
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.stringContaining('width="600"'));
|
||||
});
|
||||
|
||||
it("offers them on a right-click too, instead of the browser's menu", async () => {
|
||||
const ev = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
|
||||
await act(async () => img().dispatchEvent(ev));
|
||||
expect(ev.defaultPrevented).toBe(true);
|
||||
expect(button("Original size")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("never puts the frame or its buttons into the message", async () => {
|
||||
await act(async () => img().click());
|
||||
await act(async () => button("Small")!.click());
|
||||
const sent = onChange.mock.lastCall![0] as string;
|
||||
expect(sent).not.toContain("img-resize");
|
||||
expect(sent).not.toContain("Original size");
|
||||
expect(editor().querySelector(".img-resize-layer")).toBeNull();
|
||||
});
|
||||
|
||||
it("lets go on a click elsewhere and on Escape", async () => {
|
||||
await act(async () => img().click());
|
||||
await act(async () => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
|
||||
expect(host.querySelector(".img-resize-bar")).toBeNull();
|
||||
|
||||
await act(async () => img().click());
|
||||
await act(async () => editor().querySelector("p")!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })));
|
||||
expect(host.querySelector(".img-resize-bar")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RecipientInput } from "../RecipientInput";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
/*
|
||||
* Someone already in Cc was still offered when typing in To, so it was easy
|
||||
* to address the same person twice from two fields.
|
||||
*/
|
||||
|
||||
describe("suggestions across To, Cc and Bcc", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
useContacts.setState({
|
||||
suggest: (async () => [
|
||||
{ name: "Ann Lee", email: "[email protected]", source: "contact" },
|
||||
{ name: "Ann Taylor", email: "[email protected]", source: "contact" },
|
||||
]) as never,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("leaves out an address already in another recipient field", async () => {
|
||||
await act(async () => {
|
||||
root.render(<RecipientInput value={[]} exclude={[{ name: null, email: "[email protected]" }]} onChange={() => {}} />);
|
||||
});
|
||||
const input = host.querySelector("input")!;
|
||||
await act(async () => {
|
||||
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
set.call(input, "ann");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200);
|
||||
await Promise.resolve();
|
||||
});
|
||||
const offered = host.textContent ?? "";
|
||||
expect(offered).toContain("[email protected]");
|
||||
expect(offered).not.toContain("[email protected]");
|
||||
});
|
||||
});
|
||||
@@ -438,7 +438,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
||||
>
|
||||
{twisty ? open ? <ChevronDown size={14} /> : <ChevronRight size={14} /> : null}
|
||||
</span>
|
||||
<span className="folder-icon" style={tint ? ({ "--folder-color": tint } as React.CSSProperties) : undefined}>{icon}</span>
|
||||
<span className="folder-icon" data-role={m.role ?? (scheduled ? "scheduled" : undefined)} style={tint ? ({ "--folder-color": tint } as React.CSSProperties) : undefined}>{icon}</span>
|
||||
<span className="nav-label">{label}</span>
|
||||
{count > 0 && <span className="nav-count" title={hiddenUnread ? t("{here} here, {inSubfolders} in subfolders", { here: own, inSubfolders: hiddenUnread }) : undefined}>{count > 9999 ? "9999+" : count}</span>}
|
||||
{count > 0 && <span className="nav-dot" />}
|
||||
|
||||
@@ -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,54 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { MailboxTree } from "../MailboxTree";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import type { Mailbox, MailboxRole } from "@/jmap/types";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.matchMedia = ((q: string) => ({ matches: false, media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
|
||||
|
||||
/*
|
||||
* Role folders get their own icon tint, read from the icon's data-role; a
|
||||
* plain folder has none, and a color the reader chose still applies.
|
||||
*/
|
||||
|
||||
const rights = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true };
|
||||
const box = (id: string, name: string, role: MailboxRole, unread = 0): Mailbox => ({
|
||||
id, name, parentId: null, role, sortOrder: 0, totalEmails: unread, unreadEmails: unread, totalThreads: unread, unreadThreads: unread, myRights: rights, isSubscribed: true,
|
||||
});
|
||||
|
||||
describe("folder icons by role", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, "", "/mail/inbox");
|
||||
useMail.setState({
|
||||
mailboxes: { inbox: box("inbox", "Inbox", "inbox", 3), junk: box("junk", "Junk", "junk", 1), work: box("work", "Work", null, 2) },
|
||||
mailboxesLoaded: true,
|
||||
});
|
||||
useSettings.setState((s) => ({ settings: { ...s.settings, showHiddenFolders: false, labelsSidebar: false, folderColors: { junk: "#123456" } } }));
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
act(() => root.render(<MailboxTree />));
|
||||
});
|
||||
afterEach(() => { act(() => root.unmount()); host.remove(); });
|
||||
|
||||
const icons = () => Array.from(document.querySelectorAll<HTMLElement>(".nav-item.folder-row .folder-icon"));
|
||||
const byRole = (role: string) => icons().find((i) => i.dataset.role === role)!;
|
||||
|
||||
it("marks role folders with their role and leaves a plain folder unmarked", () => {
|
||||
expect(icons().map((i) => i.dataset.role ?? null).sort()).toEqual(["inbox", "junk", null].sort());
|
||||
});
|
||||
|
||||
it("keeps the reader's own color on a role folder", () => {
|
||||
expect(byRole("junk").style.getPropertyValue("--folder-color")).toBe("#123456");
|
||||
});
|
||||
|
||||
it("shows unread counts as pills", () => {
|
||||
const pill = Array.from(document.querySelectorAll(".nav-item.folder-row.unread .nav-count")).map((p) => p.textContent);
|
||||
expect(pill).toEqual(expect.arrayContaining(["3", "1", "2"]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ThreadView } from "../ThreadView";
|
||||
import { useMail } from "@/store/mail";
|
||||
import type { ListActions } from "../MessageList";
|
||||
import type { Email, Id } from "@/jmap/types";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver;
|
||||
window.matchMedia = ((q: string) => ({ matches: false, media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
|
||||
|
||||
/*
|
||||
* Mark as unread on an open message undid itself: the unread count showed 1
|
||||
* for a moment, then the message was read again (Gitea issue #25). Clearing
|
||||
* `$seen` re-ran the mark-read timer, which found an open unread message and
|
||||
* did its job.
|
||||
*/
|
||||
|
||||
const msg = (id: Id, seen: boolean, receivedAt: string): Email =>
|
||||
({
|
||||
id, threadId: "t1", subject: "Hello", mailboxIds: { inbox: true }, keywords: seen ? { $seen: true } : {},
|
||||
from: [{ name: "Ann", email: "[email protected]" }], to: [{ name: "Me", email: "[email protected]" }],
|
||||
receivedAt, size: 10, blobId: "b1", preview: "hi",
|
||||
htmlBody: [], textBody: [{ partId: "1", type: "text/plain" }],
|
||||
bodyValues: { "1": { value: "hi", isEncodingProblem: false, isTruncated: false } },
|
||||
attachments: [],
|
||||
}) as unknown as Email;
|
||||
|
||||
describe("marking an open message unread", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
let markRead: ReturnType<typeof vi.fn>;
|
||||
|
||||
const setEmails = async (emails: Email[]) => {
|
||||
await act(async () => {
|
||||
useMail.setState({
|
||||
threads: { t1: { id: "t1", emailIds: emails.map((e) => e.id) } } as never,
|
||||
emails: Object.fromEntries(emails.map((e) => [e.id, e])) as never,
|
||||
fullIds: Object.fromEntries(emails.map((e) => [e.id, true])) as never,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const show = async () => {
|
||||
const actions = {} as unknown as ListActions;
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ThreadView
|
||||
threadId="t1" mailboxId="inbox" messageId={null} actions={actions}
|
||||
onBack={() => undefined} onNavigate={() => undefined} hasPrev={false} hasNext={false}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const runTimers = async () => {
|
||||
await act(async () => {
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
markRead = vi.fn(async () => undefined);
|
||||
useMail.setState({
|
||||
accountId: "a1",
|
||||
loadingThreads: {} as never,
|
||||
mailboxes: { inbox: { id: "inbox", name: "Inbox", role: "inbox" } } as never,
|
||||
loadThread: (async () => undefined) as never,
|
||||
setOpenThread: (() => undefined) as never,
|
||||
markRead: markRead as never,
|
||||
roleId: (() => null) as never,
|
||||
});
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("leaves it unread instead of marking it read again", async () => {
|
||||
await setEmails([msg("m1", true, "2026-10-01T10:00:00Z")]);
|
||||
await show();
|
||||
await runTimers();
|
||||
expect(markRead).not.toHaveBeenCalled();
|
||||
|
||||
// What the button does: the server clears $seen and the store follows.
|
||||
await setEmails([msg("m1", false, "2026-10-01T10:00:00Z")]);
|
||||
await runTimers();
|
||||
expect(markRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still marks a message read that was unread when the thread opened", async () => {
|
||||
await setEmails([msg("m1", false, "2026-10-01T10:00:00Z")]);
|
||||
await show();
|
||||
await runTimers();
|
||||
expect(markRead).toHaveBeenCalledWith(["m1"], true);
|
||||
});
|
||||
|
||||
it("does not re-mark it after the timer has already read it once", async () => {
|
||||
// Opened unread, read by the timer, then marked unread by the reader.
|
||||
await setEmails([msg("m1", false, "2026-10-01T10:00:00Z")]);
|
||||
await show();
|
||||
await runTimers();
|
||||
expect(markRead).toHaveBeenCalledTimes(1);
|
||||
await setEmails([msg("m1", true, "2026-10-01T10:00:00Z")]);
|
||||
await setEmails([msg("m1", false, "2026-10-01T10:00:00Z")]);
|
||||
await runTimers();
|
||||
expect(markRead).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still marks a reply read that arrives while the thread is open", async () => {
|
||||
await setEmails([msg("m1", true, "2026-10-01T10:00:00Z")]);
|
||||
await show();
|
||||
await setEmails([msg("m1", true, "2026-10-01T10:00:00Z"), msg("m2", false, "2026-10-01T11:00:00Z")]);
|
||||
await runTimers();
|
||||
expect(markRead).toHaveBeenCalledWith(["m2"], true);
|
||||
});
|
||||
});
|
||||
@@ -113,6 +113,14 @@ export function CalendarSettings() {
|
||||
hint={t("A calendar of its own, derived from the birthdays already on your contact cards. Nothing is written anywhere — the dates stay on the cards, and an event disappears when the contact does or the birthday is cleared. It can be hidden from the calendar\u2019s own sidebar without turning it off here.")}
|
||||
/>
|
||||
|
||||
<h2>{t("Contacts")}</h2>
|
||||
<Switch
|
||||
checked={s.collectRecipients}
|
||||
onChange={(v) => update({ collectRecipients: v })}
|
||||
label={t("Save people you write to as contacts")}
|
||||
hint={t("Everyone you send to who isn\u2019t a contact yet is added to the Collected address book, so they\u2019re suggested on every device. Your own addresses are never added.")}
|
||||
/>
|
||||
|
||||
<h2>{t("Working hours")}</h2>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
|
||||
Reference in new issue
Block a user