chore: pending changes before release

This commit is contained in:
philc
2026-10-12 13:51:47 +00:00
parent 22da647b46
commit a8804fc9c2
127 changed files with 13510 additions and 969 deletions
+72 -30
View File
@@ -74,9 +74,12 @@ node {NODEPATH}/apxtri/node_modules/@tailwindcss/cli/dist/index.mjs \
If the PWA does not yet exist (creation task), compile CSS after creating the files.
### 2. Setup Draft Symlink (AUTO — node -e allowed)
### 2. Live development via the dev-tribe domain (AUTO)
Creates or verifies the `/draft/` symlink so changes are served live at `/{SUBAPP}/draft/`.
`/draft/` URLs are deprecated and must never be generated by new code
(no `pathname.includes('/draft/')`, no `/draft` link suffix, no `IS_DRAFT`).
For live development, serve the PWA through the dev tribe's domain
(pattern `.*test.*`), which serves the tribe's live sources.
```bash
node -e "
@@ -94,7 +97,7 @@ process.exit(0);
Verify: `ls -la {DATAPATH}/{TRIBE}/wwws/{APP}/{SUBAPP}/draft` should show `→ {NODEPATH}/{TRIBE}/wwws/{APP}/{SUBAPP}`.
Access in browser: `http://{DOMAIN}/{SUBAPP}/draft/` (e.g. `http://dev.zero.ndda.fr/identities/draft/`)
Access in browser: `http://{DEV-DOMAIN}/{SUBAPP}/` (e.g. `http://apptest.edifiance.com/gestion/bdc/`). Never append `/draft/`.
### 3. Auto-Manage dev-watch.js (AUTO — node -e allowed)
@@ -137,7 +140,7 @@ Behavior:
| Repeat call for PWA A | Detects same PWA → skips |
| Switch to PWA B | Kills PWA A watcher, starts B |
The `IS_DRAFT` flag (detected by `auth.js` via URL) activates `Log.debug()` automatically when accessed via `/draft/`.
The inspect mode toggle (left drawer, persisted via `Auth.getInspect()`/`Auth.setInspect()`) activates `Log.debug()` automatically. See `cdn/lib/apxtri/inspect.js`.
## Directory
- Source: `/opt/apxtowns/{TOWN}.{NATION}/{tribe}/wwws/{app}/`
@@ -257,9 +260,11 @@ After PGP auth, server sets httpOnly cookie `xauthhash`. Subsequent requests use
Caddy sert les fichiers depuis `{DATAPATH}/{tribe}/wwws/{app}/`. Les sources sont dans `{NODEPATH}/{tribe}/wwws/{app}/`.
### Draft mode (dev local)
### Live development (dev tribe domain)
Au démarrage du serveur, la méthode `_setupDraftSymlinks()` crée un dossier `draft/` dans chaque subapp du runtime (DATAPATH), contenant un **symlink** vers les sources dans NODEPATH.
At server startup, `_setupDraftSymlinks()` keeps a `draft/` symlink per runtime
subapp pointing at the NODEPATH sources (legacy mechanism, backend-owned, will be
removed by `@be-apxtri`). Frontend code must not reference it.
```
{DATAPATH}/{tribe}/wwws/{app}/{subapp}/
@@ -268,16 +273,16 @@ Au démarrage du serveur, la méthode `_setupDraftSymlinks()` crée un dossier `
├── index.html # Minifié
├── manifest.json # Copié
├── sw.js # Copié
└── draft/ → {NODEPATH}/{tribe}/wwws/{app}/{subapp}/ # Symlink source
└── draft/ → {NODEPATH}/{tribe}/wwws/{app}/{subapp}/ # Legacy symlink (do not use)
```
Les sources sont accessibles via `http://{DOMAIN}/{subapp}/draft/` (ex: `http://dev.zero.ndda.fr/identities/draft/`).
Quand l'URL contient `/draft/`, `Log.debug()`/`Log.info()`/`Log.warn()` s'activent automatiquement (via `IS_DRAFT` dans `auth.js`). Aucun meta tag nécessaire.
Develop against the dev tribe domain (`.*test.*`), which serves live sources.
When inspect mode is on, `Log.debug()`/`Log.info()`/`Log.warn()` print to the
console (via `Inspect` in `cdn/lib/apxtri/inspect.js`). No `/draft/` URL, no meta tag.
### Workflow de développement
Aucun appel API ni `buildpwa` nécessaire. Tout se fait par symlink `/draft/`.
Aucun appel API ni `buildpwa` nécessaire. Tout se fait via le domaine de dev.
```bash
# 1. Lancer le watch CSS (à laisser tourner en fond)
@@ -288,17 +293,17 @@ node dev-watch.js &
vim {NODEPATH}/{tribe}/wwws/{app}/{subapp}/js/app.js
# Modifier index.html, style.css, etc.
# 3. Navigateur — utiliser /draft/ pour voir les modifs en direct
http://{DOMAIN}/{subapp}/draft/
# Exemple : http://dev.zero.ndda.fr/identities/draft/
# 3. Navigateur — utiliser le domaine de la tribe de dev pour voir les modifs en direct
http://{DEV-DOMAIN}/{subapp}/
# Exemple : http://apptest.edifiance.com/gestion/bdc/
```
| Action | Détail |
|--------|--------|
| CSS | `dev-watch.js` surveille `style.css` → recompile `output.css` automatiquement |
| JS/HTML | Pas de build — `/draft/` = symlink vers NODEPATH → F5 suffit |
| Logs | `IS_DRAFT` (dans `auth.js`) active `Log.debug()` automatiquement via l'URL |
| Prod | URL sans `/draft/` → sert la version buildée et minifiée, sans logs |
| JS/HTML | Pas de build — le domaine de dev sert les sources → F5 suffit |
| Logs | Le mode inspect (dans `inspect.js`) active `Log.debug()` automatiquement |
| Prod | Domaine de production → sert la version buildée et minifiée, sans logs |
Vérifier le symlink :
```bash
@@ -337,7 +342,7 @@ Ce que fait `Wwws.buildpwa()` :
- Copie les fichiers statiques (fonts, static/)
- Copie tout vers `{DATAPATH}/{tribe}/wwws/{app}/` (runtime servi par Caddy)
Après le build, la version `/draft/` et la version normale sont identiques. Le build écrase le runtime existant.
Après le build, les versions dev et prod sont identiques. Le build écrase le runtime existant.
## CDN Local
@@ -403,13 +408,11 @@ Toute PWA DOIT charger et initialiser le module d'authentification partagé.
<script src="/cdn/{tribe}/lib/auth/auth.js"></script>
```
### Détection draft automatique
### Inspect mode detection
Pas de meta tag requis. `auth.js` détecte automatiquement le mode via l'URL :
```javascript
const IS_DRAFT = window.location.pathname.includes('/draft/');
```
En draft, `Log.debug()` affiche les logs dans la console. En production (hors /draft/), les logs sont silencieux.
No meta tag required. `inspect.js` exposes a global `Inspect` object (mode state,
persisted via `Auth.getInspect()`/`Auth.setInspect()`). When inspect mode is on,
`Log.debug()` prints to the console; otherwise it is silent.
### Initialisation dans `init()`
```javascript
@@ -439,9 +442,9 @@ Auth.onSwitch(() => location.reload());
## Logging (Log.debug / Log.error)
`auth.js` expose un objet `Log` global :
`inspect.js` exposes a global `Log` object :
```javascript
Log.debug('message', data); // visible uniquement si l'URL contient /draft/
Log.debug('message', data); // visible uniquement si le mode inspect est actif
Log.info('message', data); // idem
Log.warn('message', data); // idem
Log.error('message', data); // toujours visible (console.error)
@@ -451,13 +454,52 @@ Log.error('message', data); // toujours visible (console.error)
Quand `Wwws.buildpwa()` exécute terser avec `drop_console: true` :
- `console.log()`, `console.debug()`, `console.info()`, `console.warn()` → supprimés du JS minifié
- `console.error()` → conservé
- `Log.debug()` → déjà no-op hors /draft/ (silencieux par défaut)
- `Log.debug()` → déjà no-op hors mode inspect (silencieux par défaut)
### Draft vs Prod
### Inspect vs Prod
| Accès | `Log.debug()` | `console.log()` résiduels | Fichiers servis |
|-------|--------------|--------------------------|-----------------|
| `/draft/` | visible | visibles (buildpwa pas appliqué) | symlink → NODEPATH |
| normal | no-op | supprimés par terser | DATAPATH (buildpwa)
| dev + inspect ON | visible | visibles (buildpwa pas appliqué) | sources live |
| dev + inspect OFF | no-op | visibles (buildpwa pas appliqué) | sources live |
| prod | no-op | supprimés par terser | DATAPATH (buildpwa)
## data-schema Convention (MANDATORY for forms and displayed data)
Every form field and every displayed Odmdb value MUST carry `data-schema="object.property"`,
so the inspect mode can label it and developers can prompt precisely
(e.g. `"@fe-pwa modify the clients.reporting field screen"`).
```html
<input id="client-name" data-schema="clients.name" class="input input-bordered w-full">
<span data-schema="missions.status">active</span>
```
### Rules
1. **Never guess.** A wrong label is worse than none: it misleads the next prompt.
Add `data-schema` only when the mapping is unambiguous (field `id`/`name`
matches a property of a known object, or the form/screen manifestly targets
one object). Otherwise leave the field unannotated and report it.
2. **Validate every value.** Object and property must both exist in
`{NODEPATH}/apxtri/schema/*.json` or the tribe's `{NODEPATH}/{tribe}/schema/*.json`.
Check with `GET /api/apxtri/odmdb/schema/:tribe` (names) then
`GET /api/apxtri/odmdb/schema/:tribe/:objectname` (properties, one request per object).
3. **Skip (never annotate):** secrets/passwords/passphrases/private keys,
composite bodies (mail/thread bodies), fields shared by several objects
(private + public branches), lookup/filter inputs, raw JSON editors,
non-Odmdb stores (`appconf.json`, repo/invite/ACL tooling), hidden technical fields.
4. **Dynamic templates:** build the value from the loop variable, guarded by the
same condition that guarantees the property exists:
```javascript
// tribes.' + key + ' — key iterates schema properties, guarded:
if (!props[key]) return;
el.setAttribute('data-schema', 'tribes.' + key);
// persons.' + f + ' — f comes from the server-side readable list
```
5. **Rendering** is centralized: `Inspect.renderSchemaTags()` in
`cdn/lib/apxtri/inspect.js` labels annotated fields when inspect mode is on
(overlay only, no layout shift when off) and re-runs on every screen change.
6. **Report** annotated vs non-annotated counts per app at the end of each task.
## Navigation Stack (pattern obligatoire)
+20
View File
@@ -356,6 +356,26 @@ The `PATH` prefix is mandatory for `Wwws.buildpwa`: it shells out to the bare
`node` command (Tailwind CLI), so `node` must resolve from the child process's
`PATH` — a plain `sudo -u apxtri` resets the environment and loses it.
### CRITICAL: never run privileged operations yourself — always ask the user
The agent **must never** run a command that needs `sudo` (or that writes as
`apxtri`) on its own, and **must never** fall back to writing as the dev user.
Concretely:
- **PWA rebuilds** (`models/Wwws.js … pwa`), **service management**
(`sudo systemctl {start,stop,restart,status} apxtri@{town}.{nation}`), and any
write that targets `{DATAPATH}` or `{NODEPATH}` **must** be delegated to the
user: print the exact command (resolved `$NODE_BIN`, run as
`sudo -u apxtri env PATH="$NODE_BIN:$PATH" …`) and wait for the user to run it.
- **Do NOT** copy/minify files directly into `{DATAPATH}/…/wwws/` as the dev user
as a workaround. Files created under the dev user become `dev:dev`-owned and
then the real `apxtri` build fails with `EPERM` (e.g. `fs.copyFile` on
`manifest.json`) or silently mis-serves — exactly the outage this rule prevents.
- Do not attempt `sudo` if no password is available: instead hand the command to
the user and stop.
- Ownership-sensitive writes (a PWA build, `chown`, `chmod 600` on secrets) always
go through `sudo -u apxtri …`, never as the dev user.
---
## Build/Test Commands
-157
View File
@@ -9,7 +9,6 @@ const { execSync } = require("child_process");
const Odmdb = require("./models/Odmdb.js");
const Caddy = require("./models/Caddy.js");
const logger = require("./utils/logger.js");
const { getWebappSource, listWebappNames } = require("./utils/webapps.js");
/**
* Main application class for the apxtri server.
@@ -40,7 +39,6 @@ class ApxtriApp {
await this._rebuildIndexes(this.conflist);
const app = this._setupExpress(this.conflist);
await this._configureCaddy(this.conflist);
this._setupDraftSymlinks();
if (process.env.BLOCKCHAIN_CONNECT === "true"){
const Blockchain = require("./models/Blockchain.js")
Blockchain.start();
@@ -763,92 +761,6 @@ class ApxtriApp {
);
app.use(bodyParser.urlencoded(globalConf.api.bodyparse.urlencoded));
// Serve PWA subapp draft sources with the /cdn/dev/ -> /cdn/{town}/ rewrite
// (mirrors buildpwa so draft mode works on non-dev towns too).
// Caddy forwards host-based webapp drafts here with X-Apxtri-Webapp set to
// the owning webapp name; without it the subapp is resolved against mainapp.
app.use(async (req, res, next) => {
if (!["GET", "HEAD"].includes(req.method)) return next();
const m = req.path.match(/^\/([^/]+)\/draft(\/.*)?$/);
if (!m) return next();
const subapp = m[1];
const rest = m[2] || "";
const webappName = req.headers["x-apxtri-webapp"];
let appRoot = null;
let tribe = process.env.TOWN || "dev";
if (webappName) {
// Sanitize: the header is client-reachable, so it must never escape the
// webapps source tree.
const safeName = String(webappName).replace(/[^a-zA-Z0-9_-]/g, "");
if (safeName) {
try {
const Caddy = require("./models/Caddy.js");
const wa = Caddy.discoverWebapps().find((w) => w.name === safeName);
if (wa && wa.sourcePath) {
appRoot = wa.sourcePath;
tribe = wa.tribe;
}
} catch (e) { /* fall back to mainapp */ }
}
}
if (!appRoot) appRoot = path.join(this.nodePath, "apxtri", "mainapp");
const subappRoot = path.join(appRoot, subapp);
if (!fs.existsSync(path.join(subappRoot, "manifest.json"))) return next();
// /subapp/draft -> /subapp/draft/ so relative assets resolve correctly
if (rest === "") {
return res.redirect(301, req.path + "/");
}
let rel = rest.replace(/^\/+/, "");
if (rel === "" || rel.endsWith("/")) rel += "index.html";
const srcFile = path.resolve(subappRoot, rel);
if (srcFile !== subappRoot && !srcFile.startsWith(subappRoot + path.sep)) {
return res.status(400).end();
}
const ext = path.extname(srcFile).toLowerCase();
const MIME = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".webmanifest": "application/manifest+json; charset=utf-8",
};
if (!MIME[ext]) {
return res.sendFile(srcFile, (err) => {
if (err && !res.headersSent) res.status(404).end();
});
}
try {
const buf = await fs.readFile(srcFile);
let text = buf.toString("utf8");
const town = process.env.TOWN || "dev";
text = text.replace(/\/cdn\/dev\//g, "/cdn/" + town + "/");
// xtribe is the webapp's own tribe (mainapp falls back to the town), not
// the town: rewriting it to the town would point a tribe webapp draft at
// the wrong data namespace.
text = text.replace(/<meta name="xtribe" content="[^"]*"/g, `<meta name="xtribe" content="${tribe}"`);
text = text.replace(/<meta name="xnation" content="[^"]*"/g, `<meta name="xnation" content="${process.env.NATION || ""}"`);
// Draft is always a dev-mode read: sources no longer carry the xmode
// meta (it used to ship in the built dist and disable profile gating),
// so inject it here for html documents only.
if (ext === ".html" && !/<meta\s+name="xmode"/i.test(text)) {
text = text.replace(/<head([^>]*)>/i, `<head$1>\n <meta name="xmode" content="dev" />`);
}
res.setHeader("Content-Type", MIME[ext]);
res.send(text);
} catch (err) {
if (err.code === "ENOENT") return res.status(404).end();
next(err);
}
});
// Dynamically load all routes
logger.info("Loading API routes...");
conflist.apiroutes.forEach((r) => {
@@ -892,75 +804,6 @@ class ApxtriApp {
}
}
/**
* Creates draft/ symlinks for all PWA subapps across all tribes.
* Each subapp (folder containing manifest.json) gets a draft/ symlink
* in its runtime directory pointing to the source directory.
* This enables development without replacing subapps by symlinks.
* @private
*/
_setupDraftSymlinks() {
logger.info("Setting up draft symlinks for PWA development...");
const tribes = fs.readdirSync(this.dataPath).filter(d => {
try {
const dirPath = path.join(this.dataPath, d);
return fs.statSync(dirPath).isDirectory()
&& !['apxtri', 'logs', 'backup', 'tmp'].includes(d);
} catch {
return false;
}
});
let count = 0;
for (const tribe of tribes) {
const apps = listWebappNames(tribe).filter(a => a !== 'apidoc');
for (const app of apps) {
const srcBase = getWebappSource(tribe, app);
const distBase = path.join(this.dataPath, tribe, 'wwws', app);
if (!fs.existsSync(srcBase)) continue;
const entries = fs.readdirSync(srcBase, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'draft') continue;
const subSrc = path.join(srcBase, entry.name);
if (!fs.existsSync(path.join(subSrc, 'manifest.json'))) continue;
const draftPath = path.join(distBase, entry.name, 'draft');
try {
// lstat does not follow the target: detects broken/stale symlinks too
let st = null;
try { st = fs.lstatSync(draftPath); } catch { /* not found */ }
if (st) {
if (st.isSymbolicLink()) {
const existing = fs.readlinkSync(draftPath);
if (existing === subSrc) continue;
fs.unlinkSync(draftPath);
} else {
fs.removeSync(draftPath);
}
}
fs.ensureDirSync(path.dirname(draftPath));
fs.symlinkSync(subSrc, draftPath);
logger.info(`Draft symlink: ${draftPath} → ${subSrc}`);
count++;
} catch (err) {
logger.warn(`Failed to create draft symlink for ${entry.name}: ${err.message}`);
}
}
}
}
logger.info(`Draft symlinks setup complete (${count} created).`);
}
/**
* Starts the Express server listener.
* @private
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -7,6 +7,7 @@ const Auth = {
catch { this._store = {}; }
if (!this._store.identities) this._store.identities = {};
if (!this._store.theme) this._store.theme = 'light';
if (typeof this._store.inspect === 'undefined') this._store.inspect = false;
if (!this._store.lg) this._store.lg = 'en';
}
return this._store;
@@ -37,6 +38,13 @@ const Auth = {
});
},
getInspect() { return this._load().inspect === true; },
setInspect(v) {
this._load().inspect = !!v;
this._save();
},
getLang() { return this._load().lg || 'en'; },
setLang(lg) {
-28
View File
@@ -1,28 +0,0 @@
const IS_DRAFT = window.location.pathname.includes('/draft/');
const Log = {
debug(...args) { if (IS_DRAFT) console.log('[apxtri]', ...args); },
info(...args) { if (IS_DRAFT) console.info('[apxtri]', ...args); },
warn(...args) { if (IS_DRAFT) console.warn('[apxtri]', ...args); },
error(...args) { console.error('[apxtri]', ...args); },
};
if (IS_DRAFT) {
document.addEventListener('DOMContentLoaded', () => {
document.body.addEventListener('mouseover', function (evt) {
const target = evt.target.closest('button,a,label,input,[data-action]');
const actionEl = document.getElementById('draft-action');
if (!actionEl) return;
if (target) {
const action = target.dataset?.action;
if (action) {
const debugEl = document.getElementById('draft-debug');
if (debugEl) debugEl.style.display = '';
actionEl.textContent = 'data-action:' + action;
actionEl.style.display = '';
}
} else {
actionEl.style.display = 'none';
}
});
});
}
+3 -3
View File
@@ -13,9 +13,9 @@
*/
var Guard = {
_redirectLogin() {
const draft = window.location.pathname.includes('/draft/') ? '/draft' : '';
const returnUrl = encodeURIComponent(window.location.pathname.split('/draft')[0] + '/');
window.location.href = '/identities' + draft + '/?redirect=' + returnUrl;
const path = window.location.pathname.endsWith('/') ? window.location.pathname : window.location.pathname + '/';
const returnUrl = encodeURIComponent(path);
window.location.href = '/identities/?redirect=' + returnUrl;
},
_denied(allowed, profils) {
+75
View File
@@ -0,0 +1,75 @@
const Log = {
debug(...args) { if (Inspect.on()) console.log('[apxtri]', ...args); },
info(...args) { if (Inspect.on()) console.info('[apxtri]', ...args); },
warn(...args) { if (Inspect.on()) console.warn('[apxtri]', ...args); },
error(...args) { console.error('[apxtri]', ...args); },
};
const Inspect = {
_hoverBound: false,
on() {
try { return typeof Auth !== 'undefined' && Auth.getInspect() === true; }
catch (e) { return false; }
},
apply() {
const on = this.on();
const banner = document.getElementById('inspect-debug');
if (banner) banner.classList.toggle('hidden', !on);
if (on) {
this._bindHover();
this.renderSchemaTags();
} else {
const actionEl = document.getElementById('inspect-action');
if (actionEl) actionEl.classList.add('hidden');
this.removeSchemaTags();
}
},
_bindHover() {
if (this._hoverBound) return;
this._hoverBound = true;
document.addEventListener('mouseover', (evt) => {
if (!this.on()) return;
const actionEl = document.getElementById('inspect-action');
if (!actionEl || !evt.target || !evt.target.closest) return;
const target = evt.target.closest('button,a,label,input,select,textarea,[data-action],[onclick]');
const lines = [];
if (target) {
if (target.hasAttribute('data-action')) lines.push('data-action:' + target.getAttribute('data-action'));
if (target.hasAttribute('onclick')) lines.push('onclick:' + target.getAttribute('onclick'));
}
if (!lines.length) {
actionEl.classList.add('hidden');
return;
}
actionEl.textContent = '';
lines.forEach((line) => {
const el = document.createElement('div');
el.textContent = line;
actionEl.appendChild(el);
});
actionEl.classList.remove('hidden');
this.renderSchemaTags();
});
},
renderSchemaTags() {
if (!this.on()) return;
document.querySelectorAll('[data-schema]').forEach((el) => {
if (el.tagName === 'INPUT' && el.type === 'hidden') return;
const next = el.nextElementSibling;
if (next && next.hasAttribute('data-schema-tag')) return;
const tag = document.createElement('span');
tag.setAttribute('data-schema-tag', '');
tag.textContent = el.getAttribute('data-schema') || '';
tag.style.cssText = 'display:inline-block;margin-left:4px;padding:0 4px;border-radius:4px;background:#0284c7;color:#fff;font-family:monospace;font-size:10px;line-height:1.6;vertical-align:middle;pointer-events:none;';
el.insertAdjacentElement('afterend', tag);
});
},
removeSchemaTags() {
document.querySelectorAll('[data-schema-tag]').forEach((el) => el.remove());
},
};
+19 -16
View File
@@ -69,7 +69,7 @@ const Menu = {
if (target) target.classList.remove('hidden');
this._currentScreen = name;
this._updateBack();
this._updateDraftScreen(name);
this._updateInspectScreen(name);
this._updateTitle();
},
@@ -81,7 +81,7 @@ const Menu = {
if (target) target.classList.remove('hidden');
this._currentScreen = prev;
this._updateBack();
this._updateDraftScreen(prev);
this._updateInspectScreen(prev);
this._updateTitle();
},
@@ -124,11 +124,10 @@ const Menu = {
if (btn) btn.classList.toggle('hidden', this._navStack.length === 0);
},
_updateDraftScreen(name) {
const ds = document.getElementById('draft-screen');
if (ds) ds.textContent = 'screen: ' + name;
const dd = document.getElementById('draft-debug');
if (dd) dd.style.display = '';
_updateInspectScreen(name) {
const el = document.getElementById('inspect-screen');
if (el) el.textContent = 'screen: ' + name;
if (typeof Inspect !== 'undefined') Inspect.renderSchemaTags();
},
_injectHTML() {
@@ -140,13 +139,6 @@ const Menu = {
if (contentArea && children.length) {
children.forEach(child => contentArea.appendChild(child));
}
if (!document.getElementById('draft-debug')) {
const dd = document.createElement('div');
dd.id = 'draft-debug';
dd.style.cssText = 'display:none;position:fixed;bottom:8px;right:8px;z-index:50;background:#1f2937;color:#f3f4f6;font-size:11px;padding:4px 8px;border-radius:4px;font-family:monospace;opacity:0.7';
dd.innerHTML = '<div id="draft-screen">screen: -</div><div id="draft-action" style="display:none"></div>';
document.body.appendChild(dd);
}
},
_drawerHTML() {
@@ -155,6 +147,7 @@ const Menu = {
const avatar = this._profileFilter(avatarAll);
const langs = this._config?.langs || ['en', 'fr', 'de', 'it', 'es', 'nl'];
const theme = Auth.getTheme() || 'light';
const inspect = Auth.getInspect ? Auth.getInspect() : false;
const alias = Auth.getActiveAlias();
const initial = alias ? alias.charAt(0).toUpperCase() : '?';
const currentLang = Auth.getLang() || 'en';
@@ -169,10 +162,10 @@ const Menu = {
// Left drawer: nav items
const navLinks = navItems.map(i =>
'<li><a href="' + (IS_DRAFT && i.href ? i.href.replace(/^\/([^/]+)/, '/$1/draft') : i.href || '#') + '" class="btn btn-ghost btn-block justify-start gap-3" data-action="' + (i.id || '') + '" data-menu-item="' + (i.id || '') + '">' + i.label + '</a></li>'
'<li><a href="' + (i.href || '#') + '" class="btn btn-ghost btn-block justify-start gap-3" data-action="' + (i.id || '') + '" data-menu-item="' + (i.id || '') + '">' + i.label + '</a></li>'
).join('');
const leftSide = '<div class="drawer-side z-30"><label for="drawer-toggle" class="drawer-overlay"></label><div class="menu p-4 w-72 min-h-full bg-base-200 text-base-content flex flex-col"><div class="mb-4 flex items-center gap-3 pb-3 border-b border-base-300"><div class="avatar placeholder"><div class="bg-neutral text-neutral-content rounded-full w-10 h-10 flex items-center justify-center font-bold">' + initial + '</div></div><div><p class="font-bold text-sm">' + (alias || 'apxtri') + '</p><p class="text-xs opacity-50">' + (alias ? (Auth.getActiveIdentity()?.profils?.join(', ') || '') : 'Not connected') + '</p></div></div><ul class="space-y-1">' + navLinks + '</ul><div class="mt-auto pt-3 border-t border-base-300 space-y-3"><div><p class="text-sm mb-2 opacity-70">Language</p><div class="flex flex-wrap gap-2 justify-center" id="lang-selector">' + langBtns + '</div></div><div class="flex items-center justify-between"><span class="text-sm">Dark theme</span><input type="checkbox" id="theme-toggle-drawer" class="toggle toggle-primary"' + (theme === 'dark' ? ' checked' : '') + ' data-action="theme-toggle" /></div></div></div></div>';
const leftSide = '<div class="drawer-side z-30"><label for="drawer-toggle" class="drawer-overlay"></label><div class="menu p-4 w-72 min-h-full bg-base-200 text-base-content flex flex-col"><div class="mb-4 flex items-center gap-3 pb-3 border-b border-base-300"><div class="avatar placeholder"><div class="bg-neutral text-neutral-content rounded-full w-10 h-10 flex items-center justify-center font-bold">' + initial + '</div></div><div><p class="font-bold text-sm">' + (alias || 'apxtri') + '</p><p class="text-xs opacity-50">' + (alias ? (Auth.getActiveIdentity()?.profils?.join(', ') || '') : 'Not connected') + '</p></div></div><ul class="space-y-1">' + navLinks + '</ul><div class="mt-auto pt-3 border-t border-base-300 space-y-3"><div><p class="text-sm mb-2 opacity-70">Language</p><div class="flex flex-wrap gap-2 justify-center" id="lang-selector">' + langBtns + '</div></div><div class="flex items-center justify-between"><span class="text-sm">Dark theme</span><input type="checkbox" id="theme-toggle-drawer" class="toggle toggle-primary"' + (theme === 'dark' ? ' checked' : '') + ' data-action="theme-toggle" /></div><div class="flex items-center justify-between"><span class="text-sm">Inspect</span><input type="checkbox" id="inspect-toggle-drawer" class="toggle toggle-secondary"' + (inspect ? ' checked' : '') + ' data-action="inspect-toggle" /></div></div></div></div>';
// Right drawer: avatar actions (only if subapp has avatar items)
// Structure depends on the configured set so profils filtering never
@@ -211,5 +204,15 @@ const Menu = {
if (themeToggle) {
themeToggle.addEventListener('change', () => Auth.setTheme(themeToggle.checked ? 'dark' : 'light'));
}
const inspectToggle = document.querySelector('[data-action="inspect-toggle"]');
if (inspectToggle) {
inspectToggle.addEventListener('change', () => {
Auth.setInspect(inspectToggle.checked);
if (typeof Inspect !== 'undefined') Inspect.apply();
});
}
if (typeof Inspect !== 'undefined') Inspect.apply();
},
};
+1 -11
View File
@@ -54,16 +54,6 @@
return /^(https?:)?\/\//i.test(u) && !/^javascript:/i.test(u.replace(/^\s+/, '').toLowerCase());
}
// Inject the /draft/ segment into a leading /subapp/ path when running in
// draft mode, mirroring menu.js. Stored templates keep the clean path; the
// client decides the mode at render time.
function draftify(path) {
const p = String(path || '');
if (typeof IS_DRAFT === 'undefined' || !IS_DRAFT) return p;
if (p.indexOf('/draft/') !== -1) return p;
return p.replace(/^\/([^/]+)/, '/$1/draft');
}
// Resolve a button urlTemplate into an absolute URL.
// - A relative template (starting with "/" or "?") is resolved against the
// plugin's own target.dns, which the same-origin filter in renderPluginSlot
@@ -76,7 +66,7 @@
if (s.startsWith('/') || s.startsWith('?')) {
const dns = values && values.dns ? String(values.dns) : '';
const origin = (window.location.protocol + '//' + dns).replace(/\/+$/, '');
return origin + (s.startsWith('/') ? draftify(s) : ('/' + s));
return origin + (s.startsWith('/') ? s : ('/' + s));
}
return filled;
}
+11 -4
View File
@@ -1,8 +1,7 @@
const IS_DRAFT = window.location.pathname.includes('/draft/');
const Log = {
debug(...args) { if (IS_DRAFT) console.log('[apxtri]', ...args); },
info(...args) { if (IS_DRAFT) console.info('[apxtri]', ...args); },
warn(...args) { if (IS_DRAFT) console.warn('[apxtri]', ...args); },
debug(...args) { if (Auth.getInspect() === true) console.log('[apxtri]', ...args); },
info(...args) { if (Auth.getInspect() === true) console.info('[apxtri]', ...args); },
warn(...args) { if (Auth.getInspect() === true) console.warn('[apxtri]', ...args); },
error(...args) { console.error('[apxtri]', ...args); },
};
@@ -15,6 +14,7 @@ const Auth = {
catch { this._store = {}; }
if (!this._store.identities) this._store.identities = {};
if (!this._store.theme) this._store.theme = 'light';
if (typeof this._store.inspect === 'undefined') this._store.inspect = false;
if (!this._store.lg) this._store.lg = 'en';
}
return this._store;
@@ -47,6 +47,13 @@ const Auth = {
});
},
getInspect() { return this._load().inspect === true; },
setInspect(v) {
this._load().inspect = !!v;
this._save();
},
// ==================== LANGUAGE (shared preference only) ====================
getLang() { return this._load().lg || 'en'; },
+4 -4
View File
@@ -13,7 +13,7 @@
<script src="/cdn/{{TRIBE}}/lib/axios/dist/axios.min.js"></script>
<script src="/cdn/{{TRIBE}}/lib/openpgp/dist/openpgp.min.js"></script>
<script src="/cdn/{{TRIBE}}/lib/dayjs/dayjs.min.js"></script>
<script src="/cdn/{{TRIBE}}/lib/apxtri/draft.js"></script>
<script src="/cdn/{{TRIBE}}/lib/apxtri/inspect.js"></script>
<script src="/cdn/{{TRIBE}}/lib/apxtri/auth.js"></script>
<script src="/cdn/{{TRIBE}}/lib/apxtri/localdb.js"></script>
<link href="css/output.css" rel="stylesheet" />
@@ -38,9 +38,9 @@
</div>
</div>
<div id="draft-debug" class="hidden fixed bottom-2 right-2 z-50 bg-neutral text-neutral-content text-xs px-2 py-1 rounded font-mono opacity-70">
<div id="draft-screen">screen: list</div>
<div id="draft-action" class="hidden"></div>
<div id="inspect-debug" class="hidden fixed bottom-2 right-2 z-50 bg-neutral text-neutral-content text-xs px-2 py-1 rounded font-mono opacity-70">
<div id="inspect-screen">screen: list</div>
<div id="inspect-action" class="hidden"></div>
</div>
<script src="/cdn/{{TRIBE}}/lib/apxtri/menu.js"></script>
+2 -3
View File
@@ -51,9 +51,8 @@ var App = {
var activeAlias = Auth.getActiveAlias();
var identity = activeAlias ? Auth.getIdentity(activeAlias) : null;
if (!activeAlias || !identity?.auth) {
var _draft = window.location.pathname.includes('/draft/') ? '/draft' : '';
var returnUrl = encodeURIComponent(window.location.pathname.split('/draft')[0] + '/');
window.location.href = '/identities' + _draft + '/?redirect=' + returnUrl;
var returnUrl = encodeURIComponent(window.location.pathname);
window.location.href = '/identities/?redirect=' + returnUrl;
return;
}
+202
View File
@@ -0,0 +1,202 @@
# MyMail (mainapp/mymail) Architecture and Backend Interactions
## Overview
mymail is a Progressive Web App (PWA) that lives as a subapp of the mainapp (`mainapp/mymail`). It lets users synchronize external mailboxes (IMAP/POP3), read and send emails via SMTP, and store synchronized messages in Odmdb, scoped per tribe. The stack is a vanilla JavaScript SPA frontend + an Express REST API + Odmdb (JSON file storage).
- Frontend: `mainapp/mymail/` (HTML/CSS/JS, i18n, service worker)
- API routes: `routes/mails.js`, `routes/mailboxes.js` (mounted under `/api/apxtri/mails`, `/api/apxtri/mailboxes`)
- Models: `models/Mailboxes.js`, `models/mailconn.js`, `models/Mailcontent.js`, `models/Outbox.js`
- Schemas: `schema/mails.json`, `schema/mailboxes.json`
- Storage (per tribe): `{DATAPATH}/{tribe}/objects/mails/`, `{DATAPATH}/{tribe}/objects/mailboxes/`
## 1) Frontend structure (`mainapp/mymail/`)
### Files
- `index.html` — SPA layout with three screens (Home, Addmail/Druid, Mail manager), uses DaisyUI/Tailwind. Loads CDN libs: `axios`, `openpgp`, `dayjs`, `draft.js`, `auth.js`, `menu.js`, `plugins.js`. Sets CSP/meta per app.
- `js/app.js` (large monolithic app object) — state, rendering, event delegation, filters/tags, pagination, compose/reply/forward, actions, sync, plugins integration.
- `js/mdeditor.js` — SunEditor-based rich text/HTML editor used for composing/replying/forwarding.
- `static/lg/*.json` — i18n (EN/FR present; others fall back).
- `appconf.json` — webapp config + strict Content-Security-Policy.
- `sw.js`, `dev-watch.js`, `manifest.json`.
### Screens
1. **Home (`#screen-home`)** — aggregated mailboxes across all tribes the user can access. Renders mailbox cards with unread count, connection status, tribe, right (read/write), last sync. Data from `loadHome()` → `GET /api/apxtri/mailboxes`.
2. **Addmail/Druid (`#screen-addmail`, `#screen-druid`)** — create/edit/test mailbox, manage access (grant/revoke reader/writer per alias), manage mailbox tags (`availableTags`). Druid-only actions enforced server-side.
3. **Mail manager (`#screen-mail`)** — two-pane on desktop (list + detail), single-pane on mobile. Filters: status, sender (`from`), free text search (`q`), tag filter (OR/AND). Batch actions (mark read/unread, trash), tag add/remove, pagination, compose/reply/forward, attachments preview/download.
### Frontend state (key fields in `App`)
- `mailboxes[]`, `currentMailbox`, `currentTribe`
- `mails[]`, `currentDetail` (`{ emailId, data }`), `selected` (Set)
- `page`, `limit`, `total`
- `_tagFilter` (Set), `_tagFilterMode` (`or`|`and`)
- `isDruid`, `tribes`, `_membersDruid`
- loading overlay via `setLoading()`
### API calls (axios)
All calls use `validateStatus: () => true` and expect normalized responses `{ status, ref, msg, data }`.
| Action | Method | Endpoint |
|---|---|---|
| List aggregated mailboxes | GET | `/api/apxtri/mailboxes` |
| List mailboxes for tribe | GET | `/api/apxtri/mailboxes/:tribe` |
| Test config (live) | POST | `/api/apxtri/mailboxes/test` |
| Create mailbox | POST | `/api/apxtri/mailboxes/:mailboxid` |
| Update mailbox | PUT | `/api/apxtri/mailboxes/:mailboxid` |
| Delete mailbox | DELETE | `/api/apxtri/mailboxes/:mailboxid` |
| Sync mailbox | POST | `/api/apxtri/mailboxes/:mailboxid/sync` |
| Grant/revoke access | PUT | `/api/apxtri/mailboxes/:mailboxid/access` (body `{alias,right}` or `{alias,right:null}`) |
| Set available tags | PUT | `/api/apxtri/mailboxes/:mailboxid/tags` (body `{tags:[{key,label,color?}]}`) |
| List mails | GET | `/api/apxtri/mails/:tribe?mailboxid=&status=&from=&q=&page=&limit=&sent=&tags=&tagMode=` |
| Get mail detail | GET | `/api/apxtri/mails/:tribe/item/:emailid` |
| Get attachment | GET | `/api/apxtri/mails/:tribe/attachment/:emailid/:index?disposition=inline` |
| Apply mail action | POST | `/api/apxtri/mails/:tribe/action` (body `{emailIds,action,tag?}`; action in `read\|unread\|trash\|tag\|untag`) |
| Send mail (Outbox) | POST/PUT | `/api/apxtri/outbox/:tribe` |
| Plugins (Odmdb) | POST/GET/PUT/DELETE | `/api/apxtri/odmdb/.../plugins/...` |
### Detail rendering & attachments
- `openMailDetail()` fetches item; if backend has no `bodyRef`, body is fetched on-demand and cached. Frontend receives `data.body` (HTML or text). HTML rendered in an iframe (`#detail-body iframe`, styled with `flex-1 min-h-0`).
- Attachments: inline preview allowed only for safe types (PDF/images/text per backend allowlist). Served via blob/route with `disposition=inline` when previewed; otherwise download.
- Tags UI: `renderMailTags()` shows existing tags (with remove), add button + dropdown populated from mailbox `availableTags`.
## 2) Backend — Routes
### `routes/mailboxes.js` (mounted at `/api/apxtri/mailboxes`)
Middlewares: `checkHeaders()`, `resolveTribeFromHost()` on tribe-scoped routes, `isAuthenticated()`.
| Method | Path | Notes |
|---|---|---|
| GET | `/` | Aggregated mailboxes across all attached tribes visible to caller (`Mailboxes.listForUser()`). |
| GET | `/:tribe` | Mailboxes in tribe visible to caller (`listForAlias()`). |
| POST | `/test` | Live test IMAP/POP3/SMTP config (druid only). Does not persist. |
| POST | `/:mailboxid` | Create mailbox (druid of town/tribe or tribe owner). Requires APX3 agreement/volume. Persists doc + secrets. |
| PUT | `/:mailboxid` | Update mailbox (druid only). Identity/auth fields protected. |
| DELETE | `/:mailboxid` | Delete mailbox + secrets (druid only). Synchronized mails remain. |
| POST | `/:mailboxid/sync` | Sync provider into tribe mails (requires read right). |
| PUT | `/:mailboxid/access` | Grant/revoke granular access: sets virtual profiles `mail-{mailboxid}-reader` or `mail-{mailboxid}-writer` on target alias person in attached tribe (druid only). `right=null` revokes. |
| PUT | `/:mailboxid/tags` | Set `availableTags` list for mailbox (druid only). Validates keys/labels/colors. |
### `routes/mails.js` (mounted at `/api/apxtri/mails/:tribe`)
| Method | Path | Notes |
|---|---|---|
| GET | `/` | Paginated list with filters (mailboxid,status,from,q,page,limit,sent,tags,tagMode). Filtered to mailboxes caller can read. |
| GET | `/item/:emailid` | Mail detail. If `bodyRef` empty, calls `Mailboxes.getMailBody()` (on-demand fetch + cache). Returns item with `body` if cached file exists. |
| GET | `/attachment/:emailid/:index` | Streams attachment. Inline only if `disposition=inline` AND content-type in `INLINE_MIME_ALLOWLIST` (`image/*`, `application/pdf`, `text/*`, `application/json`, etc.). Always sets `X-Content-Type-Options: nosniff` and restrictive CSP/sandbox for inline. |
| POST | `/action` | Apply action: `tag`/`untag` → `applyTag()`; else `read\|unread\|trash` → `applyAction()`. Requires writer right on each mail’s mailbox. `trash` moves message to provider trash folder. |
### Granular access model
Access is per-mailbox via virtual profiles stored on the person document in the attached tribe:
- `mail-{mailboxid}-reader` — read-only access to that mailbox’s mails
- `mail-{mailboxid}-writer` — read + actions (read/unread/trash/tag/untag)
Druids (town/tribe or tribe owner) see all mailboxes in scope. Access management (`setAccess/removeAccess`) patches `persons.profils` in the attached tribe.
## 3) Backend — Models
### `models/Mailboxes.js` (orchestrator)
Central model for persistence, rights, sync orchestration.
Key responsibilities:
- Resolve storage paths: mailbox docs+secrets in `{DATAPATH}/{tribe}/objects/mailboxes/{itm,secrets}`, mails in `{DATAPATH}/{tribe}/objects/mails/itm`, attachments in `{DATAPATH}/{tribe}/objects/mails/files/{emailId}/`.
- Secrets: SMTP/IMAP/POP3 password stored in `secrets/{mailboxid}.json` (chmod 600), never in mailbox document.
- Rights: `_rightFor()` determines effective right (`read`/`write`/`none`). Druids (town/tribe/owner) get broad access; otherwise checks virtual profiles.
- Listing: `listForUser()` scans all tribes and returns aggregated visible mailboxes; `listForAlias()`/`readableMailboxIds()` filter per tribe.
- Mails listing: `listMails()` filters by readable mailboxids, parses tags (`or`/`and`), prefers index `by_mailboxid.json` (fallback full scan), sorts by `dateReceived` desc, paginates.
- Access mgmt: `setAccess()`/`removeAccess()` patch `persons.profils` in attached tribe.
- Tags: `setAvailableTags()` validates `{key,label,color}` (color in DaisyUI set) and stores on mailbox.
- Actions: `applyAction()` updates mail flags/status (Odmdb). For `trash`, also moves message to provider trash via `mailconn`. `applyTag()` adds/removes keys in `mail.tags` (normalized).
- Sync: `syncMailbox()` coordinates provider sync with in-process locks (`SYNC_LOCKS`, stale after 10 min). Uses per-folder `syncCursor` `{uidValidity,lastUid,lastRemoteNum,updatedAt}` for incremental sync (`CURSOR_MAX_RESULTS=500`); falls back to full scan (`FULL_SCAN_MAX_RESULTS=50`). Updates counts, lastSync/lastError/connected, upserts mails.
- Body/attachments: `getMailBody()` fetches on-demand if missing, normalizes via `Mailcontent`, writes body file at `bodyRef` (relative path under tribe), updates mail. `getAttachment()` fetches on first access in lazy mode (or uses cached in eager), writes to `files/{emailId}/`, updates `attachments[].storageName`, returns file path.
- Public projection: `_boxPublic()` strips secrets/connector internals as appropriate for API responses.
### `models/mailconn.js` (provider adapters)
Implements IMAP (imapflow), POP3 (poplib), SMTP (nodemailer).
- Parses RFC822 via `mailparser.simpleParser()` into normalized mail structure (`mapParsedMessage()`).
- IMAP incremental: uses `UIDVALIDITY` + `lastUid`; if `uidValidity` changes, discards cursor and rescans.
- POP3 incremental: uses `lastRemoteNum`.
- Safety caps: `CURSOR_MAX_RESULTS`, `FULL_SCAN_MAX_RESULTS`, `ATTACHMENT_MAX_BYTES` (20 MB max buffer).
- Closes sessions on success/failure.
### `models/Mailcontent.js` (HTML sanitization)
Server-side HTML allow-list sanitizer (jsdom/parse5) for outbound bodies. Generates text/plain alternative. Enforces `MAX_HTML_LENGTH`/`MAX_TEXT_LENGTH`/`MAX_INPUT_LENGTH`. Drops active content with subtree (`script`, `style`, `iframe`, `object`, `embed`, `svg`, `form` controls, etc.), unwraps unknown tags safely, and only allows a restricted set of elements/attributes (with href/cite validated).
### `models/Outbox.js`
Manages outgoing drafts/sends. Uses `Mailcontent.sanitiseHtml()` and ensures text/plain alternative exists. Builds `inReplyTo`/`references` threading headers, resolves from mailbox (SMTP), sends via nodemailer. Writes to `objects/outbox/itm/` in tribe namespace.
## 4) Storage, Schemas & Odmdb
### Schemas
- `schema/mails.json` (collection `mails`)
- `apxid = emailId`, `apxuniquekey = ["emailId"]`
- Indexes: `lst_emailId` (array), `by_mailboxid` (distribution), `by_status` (distribution), `by_threadId` (distribution), `by_from` (distribution, by `from.email`), `by_tags` (array)
- `apxaccessrights`: `druids` has empty C/R/U/D (fine-grained rights enforced in routes/models via virtual mailbox profiles), `owner.R` empty
- Key fields: `emailId`, `mailboxid`, `tribe`, `provider`, `folder`, `remoteUid`, `threadId`, `inReplyTo`, `references[]`, `subject`, `snippet`, `from{email,name}`, `to/cc/bcc/replyTo[]`, `messageId`, `contentType`, `size`, `flags{seen,unread,draft,starred,answered,important}`, `hasAttachments`, `attachments[]` (part/filename/mimeType/size/contentId/origin/part/storageName/sourceUrl), `dateSent`, `dateReceived`, `labels[]`, `status` enum (`new|read|archived|trash`), `tags[]`, `bodyRef` (relative path to cached body file), `actions[]`, `owner`, `dt_create`, `dt_update`
- `schema/mailboxes.json` (collection `mailboxes`)
- `apxid = mailboxid`, `apxuniquekey = ["mailboxid"]`
- Indexes: `lst_mailboxid`, `by_tribe`, `by_status`
- `apxaccessrights.druids` empty (enforced by model)
- Key fields: `mailboxid`, `provider` enum (`imap|pop3`), `connector` (public, no secrets) includes `host/port/tls/user/folder/trashFolder/sentFolder/deleteOnDownload/rejectUnauthorized/smtp{host,port,tls}`, `connected`, `lastTest{ok,latency,smtp,dt,error}`, `lastError`, `lastErrorAt`, `email`, `label`, `tribe`, `syncMode` (`poll`), `pollInterval`, `pollIntervalMin`, `nextSync`, `syncCursor{folderName:{uidValidity,lastUid,lastRemoteNum,updatedAt}}`, `syncing{alias,since,remote}`, `downloadMode` (`lazy|eager`), `counts{total,unread}`, `status` enum (`active|paused|error`), `lastSync`, `history[]`, `availableTags[]` `{key,label,color enum}`, `apx3{agree,volumePerWeek,dtAgree}`, `owner`, timestamps
Note: `mailtags.json` does not exist as a separate schema — tags are defined per mailbox via `availableTags`.
### Filesystem layout (Odmdb JSON)
- `{DATAPATH}/{tribe}/objects/mailboxes/itm/{mailboxid}.json` — mailbox document
- `{DATAPATH}/{tribe}/objects/mailboxes/secrets/{mailboxid}.json` — credentials (chmod 600)
- `{DATAPATH}/{tribe}/objects/mailboxes/idx/*.json` — Odmdb indexes
- `{DATAPATH}/{tribe}/objects/mails/itm/{emailIdSafe}.json` — individual mail
- `{DATAPATH}/{tribe}/objects/mails/files/{emailId}/...` — cached attachment files (lazy/eager)
- `{DATAPATH}/{tribe}/objects/mails/idx/*.json` — Odmdb indexes
`emailIdSafe` is derived by normalizing `emailId` to filesystem-safe chars (`[^a-zA-Z0-9_-]` → `_`).
## 5) Data flows
### Create mailbox (druid)
1. UI → `POST /mailboxes/test` with connector+credentials (live test). Must succeed.
2. UI → `POST /mailboxes/:mailboxid` with full config + `apx3Agree`/`apx3VolumePerWeek`.
3. Backend (`Mailboxes.createMailbox`) validates druid rights (town/tribe/owner), checks uniqueness, persists mailbox doc + secrets, initializes counts/cursor. Returns public view.
### Sync
1. UI → `POST /mailboxes/:mailboxid/sync` (read right required).
2. `Mailboxes.syncMailbox()` acquires lock (`syncing`), loads secrets+mailbox, calls `mailconn.syncImap()` or `syncPop3()` using `syncCursor`.
3. Provider returns new messages (UID > lastUid or remoteNum > lastRemoteNum). Parsed → mapped → upserted in `mails/itm`. Updates `counts`, `syncCursor`, `lastSync`/`connected`. Releases lock (or stale lock reclaimed after 10 min).
### List & open mail
1. Home loads aggregated mailboxes: `GET /mailboxes`.
2. Select mailbox → `openMailbox()` fetches list: `GET /mails/:tribe?mailboxid=...&page=1&limit=25`.
3. Click mail → `GET /mails/:tribe/item/:emailid`. If `bodyRef` empty, backend fetches body on-demand (`getMailBody()`), caches to file, updates mail, returns item with `body`. Frontend renders HTML in iframe or text.
4. Mark read happens optimistically in UI; persisted via actions/sync flags.
### Actions (read/unread/trash/tag/untag)
1. UI → `POST /mails/:tribe/action` with `emailIds`, `action`, optional `tag`.
2. Backend checks writer right on each mailbox. `applyAction()` updates mail status/flags in Odmdb; for `trash` also issues provider move to trash folder. `applyTag()` updates `mail.tags` (union/difference by key).
### Send mail
1. Compose/reply/forward in UI → payload (to/cc/bcc/subject/html/text, threading headers). POST/PUT to `/api/apxtri/outbox/:tribe`.
2. `Outbox.createDraft()`/`send()` sanitizes HTML via `Mailcontent.sanitiseHtml()`, ensures text/plain alternative, resolves SMTP from sending mailbox, sends via nodemailer. Outbox entry stored in tribe’s `objects/outbox/itm/`.
### Attachments
- Lazy by default (`downloadMode=lazy`): `attachments[].storageName` empty until first access. `GET /attachment/:index?disposition=inline` triggers `getAttachment()` to download from provider, write file under `files/{emailId}/`, update mail, return file.
- Eager: fetched during sync (per mailbox config).
- Inline preview restricted to allowlisted MIME types; always served with `nosniff` and sandboxing.
## 6) Security notes
- Secrets stored separately with restrictive permissions (chmod 600). Never returned in API responses.
- Granular per-mailbox access enforced via virtual profiles + model checks.
- HTML outbound sanitized (allow-list). HTML display of fetched mails is rendered in iframe; inline attachments use sandbox + nosniff + CSP.
- Provider passwords/secrets never logged. Sync locks prevent concurrent sync storms; stale locks reclaimed.
- Attachment size capped (`ATTACHMENT_MAX_BYTES`).
- Content-Disposition/inline rules prevent executing active content.
## 7) Dependencies (notable)
- Frontend CDN: axios, openpgp, dayjs, SunEditor (via mdeditor), apxtri libs (auth/menu/plugins/draft)
- Backend: `imapflow`, `poplib`, `mailparser`, `nodemailer`, `jsdom` (for Mailcontent sanitization), `fs-extra`, `dayjs`
## Quick map for changes
- UI tweaks: `mainapp/mymail/index.html`, `mainapp/mymail/js/app.js`, `static/lg/*.json`
- API surface: `routes/mailboxes.js`, `routes/mails.js`
- Sync/provider logic: `models/Mailboxes.js`, `models/mailconn.js`
- Sanitization/send: `models/Mailcontent.js`, `models/Outbox.js`
- Schema changes: `schema/mails.json`, `schema/mailboxes.json`
- Permission model: `Mailboxes._rightFor()`, `setAccess()`/`removeAccess()` (virtual profiles)
+10
View File
@@ -44,6 +44,16 @@
"Wwws":"apxtri/models/tplstrings/Wwws"
}
},
"mycontacts":{
"version":1,
"profils":["druids"],
"schema":["apxtri/schema/contacts"],
"options":{},
"ref":{
"Contacts":"apxtri/models/tplstrings/Contacts",
"Wwws":"apxtri/models/tplstrings/Wwws"
}
},
"mywallet":{
"version":1,
"profils":["anonymous"],
+4 -4
View File
@@ -14,7 +14,7 @@
<meta name="xtribe" content="dev" />
<meta name="xnation" content="zero" />
<script src="/cdn/dev/lib/axios/dist/axios.min.js"></script>
<script src="/cdn/dev/lib/apxtri/draft.js"></script>
<script src="/cdn/dev/lib/apxtri/inspect.js"></script>
<script src="/cdn/dev/lib/apxtri/auth.js"></script>
<script src="/cdn/dev/lib/apxtri/localdb.js"></script>
<script src="/cdn/dev/lib/apxtri/menu.js"></script>
@@ -80,9 +80,9 @@
</div>
</div>
<div id="draft-debug" class="hidden fixed bottom-2 right-2 z-50 bg-neutral text-neutral-content text-xs px-2 py-1 rounded font-mono opacity-70">
<div id="draft-screen">screen: home</div>
<div id="draft-action" class="hidden"></div>
<div id="inspect-debug" class="hidden fixed bottom-2 right-2 z-50 bg-neutral text-neutral-content text-xs px-2 py-1 rounded font-mono opacity-70">
<div id="inspect-screen">screen: home</div>
<div id="inspect-action" class="hidden"></div>
</div>
<script src="/cdn/dev/lib/apxtri/notify.js"></script>
+4 -4
View File
@@ -224,11 +224,11 @@ Authenticated dashboard showing alias, town, profile badges, tribe membership, a
| # | Action | Target | Key elements in target state |
|--:|--------|--------|------------------------------|
| 1 | "Manage Town" (major only) | `/mytown/draft/` or `/mytown/` | Town management sub-app (Coming Soon) |
| 2 | "Manage" per tribe (major/druid) | `/mytribes/draft/{tribe}/` or `/mytribes/{tribe}/` | Tribe management sub-app (Coming Soon) |
| 1 | "Manage Town" (major only) | `/mytown/` | Town management sub-app (Coming Soon) |
| 2 | "Manage" per tribe (major/druid) | `/mytribes/{tribe}/` | Tribe management sub-app (Coming Soon) |
| 3 | "Go" per tribe (person) | `/{tribe}/` | Tribe's main PWA |
| 4 | "Explore Nation" | `/mynation/draft/` or `/mynation/` | Select nation --> view towns + public tribes |
| 5 | "+ Create a Tribe" | `/mytribes/draft/` or `/mytribes/` | Tribe creation sub-app (Coming Soon) |
| 4 | "Explore Nation" | `/mynation/` | Select nation --> view towns + public tribes |
| 5 | "+ Create a Tribe" | `/mytribes/` | Tribe creation sub-app (Coming Soon) |
| 6 | Click app shortcut (Messaging) | `/mythread/` (external PWA) | Tribe messaging application |
| 7 | Click app shortcut (Wallet) | `/wallet/` (external PWA) | Tribe blockchain wallet |
| 8 | Click app shortcut (Profile) | `/profil/` (external PWA) | User profile management |
+13 -8
View File
@@ -18,7 +18,7 @@
<script src="/cdn/dev/lib/dayjs/dayjs.min.js"></script>
<script src="/cdn/dev/lib/dayjs/plugin/relativeTime.js"></script>
<script src="/cdn/dev/lib/bip39/bip39.js"></script>
<script src="/cdn/dev/lib/apxtri/draft.js"></script>
<script src="/cdn/dev/lib/apxtri/inspect.js"></script>
<script src="/cdn/dev/lib/apxtri/auth.js"></script>
<script src="/cdn/dev/lib/apxtri/localdb.js"></script>
<script src="/cdn/dev/lib/qr-code-styling/lib/qr-code-styling.js"></script>
@@ -92,7 +92,7 @@
><span class="label-text" data-i18n="create.alias_label">Alias *</span></label
>
<input
id="create-alias"
id="create-alias" data-schema="pagans.alias"
placeholder="monalias"
class="input input-bordered w-full"
pattern="[a-z0-9]*"
@@ -198,7 +198,7 @@
<div class="form-control">
<label class="label"><span class="label-text" data-i18n="signin.alias_label">Alias</span></label>
<input
id="signin-alias"
id="signin-alias" data-schema="pagans.alias"
placeholder="alias"
class="input input-bordered w-full"
autocomplete="off"
@@ -263,7 +263,7 @@
<span id="myworld-avatar-badge" class="absolute -bottom-0.5 -right-0.5 hidden"></span>
</div>
<div>
<p class="text-lg font-bold" id="myworld-alias">-</p>
<p class="text-lg font-bold" id="myworld-alias" data-schema="pagans.alias">-</p>
<p class="text-xs opacity-60" id="myworld-town-nation">-</p>
</div>
<div class="flex-1 flex items-center justify-center gap-1">
@@ -311,24 +311,24 @@
<div class="grid grid-cols-2 gap-2 mb-2 items-center">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs" data-i18n="profile.color">My Color</span></label>
<input type="color" id="edit-color" value="#3b82f6" class="input input-bordered w-1/3 h-10 p-1 cursor-pointer mx-auto">
<input type="color" id="edit-color" data-schema="pagans.color" value="#3b82f6" class="input input-bordered w-1/3 h-10 p-1 cursor-pointer mx-auto">
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs" data-i18n="profile.genre">Genre</span></label>
<select id="edit-genre" class="select select-bordered select-xs w-1/2 mx-auto">
<select id="edit-genre" data-schema="pagans.genre" class="select select-bordered select-xs w-1/2 mx-auto">
<option value="">—</option>
</select>
</div>
</div>
<div class="form-control mb-3">
<label class="label py-1"><span class="label-text text-xs" data-i18n="profile.agerange">Age Range</span></label>
<select id="edit-agerange" class="select select-bordered select-xs w-full"></select>
<select id="edit-agerange" data-schema="pagans.agerange" class="select select-bordered select-xs w-full"></select>
</div>
<div class="form-control mb-3">
<label class="label"><span class="label-text text-xs" data-i18n="profile.languages">Languages</span></label>
<div class="flex flex-wrap gap-1 items-center" id="webapp-multiselect-profile-languages" data-show="label">
<input type="hidden" id="webapp-field-profile-languages" value="[]">
<input type="hidden" id="webapp-field-profile-languages" data-schema="pagans.languages" value="[]">
<button class="btn btn-xs btn-ghost btn-square" data-action="multiselect-open" data-prefix="profile" data-key="languages" data-label="Languages">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-pen-icon lucide-square-pen"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg>
</button>
@@ -521,6 +521,11 @@
</div>
<script src="/cdn/dev/lib/apxtri/menu.js"></script>
<script src="/cdn/dev/lib/apxtri/notify.js"></script>
<div id="inspect-debug" class="hidden fixed bottom-2 right-2 z-50 bg-neutral text-neutral-content text-xs px-2 py-1 rounded font-mono opacity-70">
<div id="inspect-screen">screen: list</div>
<div id="inspect-action" class="hidden"></div>
</div>
<script src="js/app.js"></script>
<dialog id="install-modal" class="modal">
<div class="modal-box bg-base-100">
+3 -3
View File
@@ -4,7 +4,7 @@
* Variable names: descriptive names throughout (alias, profile, passphrase, etc.).
* BUILD: formatted with prettier for readability; minified by buildpwa (terser) for production.
*
* DRAFT MODE: screen badge + hover data-action.
* INSPECT MODE: screen badge + hover data-action.
* ALL selectors use [data-action]; every clickable element has data-action attribute.
*/
if (window.dayjs_plugin_relativeTime) dayjs.extend(window.dayjs_plugin_relativeTime);
@@ -1218,9 +1218,9 @@ const APP_TRIBE = document.querySelector('meta[name="xtribe"]')?.content || 'dev
document.querySelectorAll('[data-action="logout"]')
.forEach((btn) => btn.addEventListener('click', () => this.handleLogout()));
document.querySelectorAll('[data-action="goto-mytribes"]')
.forEach(btn => btn.addEventListener('click', () => { window.location.href = IS_DRAFT ? '/mytribes/draft/' : '/mytribes/'; }));
.forEach(btn => btn.addEventListener('click', () => { window.location.href = '/mytribes/'; }));
document.querySelectorAll('[data-action="goto-mywallet"]')
.forEach(btn => btn.addEventListener('click', () => { window.location.href = IS_DRAFT ? '/mywallet/draft/' : '/mywallet/'; }));
.forEach(btn => btn.addEventListener('click', () => { window.location.href = '/mywallet/'; }));
document.querySelector('[data-action="recover-key"]')?.addEventListener('click', async () => {
const input = document.getElementById('forgetkey-input').value.trim();
if (input) {
+9 -17
View File
@@ -24,25 +24,17 @@ document.addEventListener('DOMContentLoaded', () => {
const drawerToggle = document.getElementById('drawer-toggle');
if ('serviceWorker' in navigator) {
// No service worker in draft/dev mode — avoid cache issues during development
if (window.location.pathname.includes('/draft/')) {
navigator.serviceWorker.getRegistrations().then(registrations => {
registrations.forEach(reg => reg.unregister());
console.log('[draft] Service Workers unregistered — no cache in dev mode');
});
} else {
navigator.serviceWorker.register('./sw.js').then(registration => {
swRegistration = registration;
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
console.log('Nouvelle version disponible');
}
});
navigator.serviceWorker.register('./sw.js').then(registration => {
swRegistration = registration;
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
console.log('Nouvelle version disponible');
}
});
});
}
});
}
const installModal = document.getElementById('install-modal');
-5
View File
@@ -22,11 +22,6 @@ self.addEventListener('install', event => {
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
// Bypass cache for draft mode — always serve from network
if (event.request.url.includes('/draft/')) {
return event.respondWith(fetch(event.request));
}
event.respondWith(
fetch(event.request)
.then(response => {
+9
View File
@@ -0,0 +1,9 @@
@import "/opt/apxtowns/main_modules/tailwindcss/theme";
@import "/opt/apxtowns/main_modules/tailwindcss/utilities";
@source "../*.html";
@source "../js/*.js";
@source "../../../cdn/lib/**/*.js";
@theme {
--font-sans: "Inter", sans-serif;
}
+32
View File
@@ -0,0 +1,32 @@
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const CSS_SRC = path.join(__dirname, 'css', 'style.css');
const CSS_OUT = path.join(__dirname, 'css', 'output.css');
const TAILWIND_CLI = path.join(__dirname, '..', '..', '..', '..', 'apxtri', 'node_modules', '@tailwindcss', 'cli', 'dist', 'index.mjs');
var lastCompile = 0;
function compileCSS() {
var cmd = process.execPath + ' "' + TAILWIND_CLI + '" -i "' + CSS_SRC + '" -o "' + CSS_OUT + '" --minify';
try {
execSync(cmd, { stdio: 'inherit' });
lastCompile = Date.now();
console.log('[' + new Date().toLocaleTimeString() + '] CSS compiled');
} catch (e) {
console.error('CSS compile error:', e.message);
}
}
console.log('Watching ' + CSS_SRC + ' for changes...');
compileCSS();
fs.watch(CSS_SRC, function (eventType) {
if (eventType === 'change') {
var now = Date.now();
if (now - lastCompile > 500) {
compileCSS();
}
}
});
+146
View File
@@ -0,0 +1,146 @@
<!DOCTYPE html>
<html lang="en" data-theme="webapp-dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" />
<title data-i18n="app.title">My Contacts - apxtri</title>
<link rel="manifest" href="manifest.json" />
<link rel="icon" href="/cdn/dev/static/img/logo/logoX.svg" type="image/svg+xml" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="My Contacts" />
<meta name="xmode" content="dev" />
<meta name="xapp" content="mainapp" />
<meta name="xtribe" content="dev" />
<script src="/cdn/dev/lib/axios/dist/axios.min.js"></script>
<script src="/cdn/dev/lib/openpgp/dist/openpgp.min.js"></script>
<script src="/cdn/dev/lib/dayjs/dayjs.min.js"></script>
<script src="/cdn/dev/lib/apxtri/inspect.js"></script>
<script src="/cdn/dev/lib/apxtri/auth.js"></script>
<script src="/cdn/dev/lib/apxtri/localdb.js"></script>
<link href="/cdn/dev/css/base.css" rel="stylesheet" /><link href="/cdn/dev/css/mycontacts.css" rel="stylesheet" />
<style>@media (max-width: 640px){.label-text,.badge,.label,.card-body p,.card-body h3{white-space:normal!important;word-break:break-word;overflow-wrap:anywhere}.card{overflow-wrap:anywhere}input.input,textarea.textarea,select.select{min-width:0}}</style>
</head>
<body class="min-h-screen bg-base-200">
<div id="loading-overlay" class="fixed inset-0 bg-base-100 z-50 flex items-center justify-center transition-opacity duration-500">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
<div id="menu-container">
<div class="max-w-[600px] mx-auto w-full px-4 py-4" id="app-content">
<div id="msginfo" class="mb-3 text-sm hidden"></div>
<!-- SCREEN: list -->
<div id="screen-list" class="screen">
<div class="flex items-center gap-2 mb-3">
<h2 class="text-lg font-bold flex-1 min-w-0 truncate" data-i18n="list.title">Contacts</h2>
<span id="active-tribe" class="badge badge-primary badge-sm hidden cursor-pointer" data-action="tribe-change" title="Change tribe"></span>
<button class="btn btn-primary btn-sm shrink-0" data-action="contact-new" data-i18n="list.new">+ New</button>
</div>
<div class="flex flex-col gap-2 mb-3">
<div class="flex gap-2">
<input id="list-search" type="text" class="input input-bordered input-sm flex-1 min-w-0" data-i18n="list.search_placeholder" placeholder="Search name, email, phone..." autocomplete="off" />
<select id="list-kind" class="select select-bordered select-sm shrink-0">
<option value="" data-i18n="list.kind_all">All</option>
<option value="individual" data-i18n="detail.kind_individual">Individual</option>
<option value="org" data-i18n="detail.kind_org">Organization</option>
</select>
</div>
<div class="flex gap-2 items-center">
<button id="duplicates-badge" class="btn btn-xs btn-warning hidden" data-action="goto-duplicates"></button>
<span id="list-count" class="text-xs opacity-50 ml-auto"></span>
</div>
</div>
<div id="tribe-picker" class="hidden space-y-2"></div>
<div id="list-contacts" class="space-y-2"></div>
</div>
<!-- SCREEN: detail -->
<div id="screen-detail" class="screen hidden">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title" id="detail-title" data-i18n="detail.title">Contact</h2>
<p class="text-xs opacity-50 font-mono break-all" id="detail-uid"></p>
<div id="detail-form" class="space-y-3 mt-2"></div>
<div class="flex gap-2 mt-3">
<button class="btn btn-ghost flex-1" data-action="back" data-i18n="app.back">Back</button>
<button class="btn btn-error btn-outline flex-1 hidden" id="btn-detail-delete" data-action="contact-delete-ask" data-i18n="detail.delete">Delete</button>
<button class="btn btn-primary flex-1" data-action="contact-save" data-i18n="app.save">Save</button>
</div>
</div>
</div>
</div>
<!-- SCREEN: duplicates -->
<div id="screen-duplicates" class="screen hidden">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-bold" data-i18n="duplicates.title">Possible duplicates</h2>
<button class="btn btn-xs btn-ghost" data-action="back" data-i18n="app.back">Back</button>
</div>
<p class="text-sm opacity-60 mb-3" data-i18n="duplicates.subtitle">Pairs sharing an email, a phone or the exact same name.</p>
<div id="duplicates-list" class="space-y-3"></div>
</div>
<!-- SCREEN: merge -->
<div id="screen-merge" class="screen hidden">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-bold" data-i18n="merge.title">Merge contacts</h2>
<button class="btn btn-xs btn-ghost" data-action="back" data-i18n="app.back">Back</button>
</div>
<p class="text-sm opacity-60 mb-3" data-i18n="merge.subtitle">Pick the surviving contact, then choose which conflicting fields to merge.</p>
<div id="merge-keep" class="grid grid-cols-1 md:grid-cols-2 gap-2 mb-3"></div>
<div id="merge-fields" class="space-y-2"></div>
<button class="btn btn-primary w-full mt-3" data-action="merge-ask" data-i18n="merge.submit">Merge</button>
</div>
</div>
</div>
<div id="inspect-debug" class="hidden fixed bottom-2 right-2 z-50 bg-neutral text-neutral-content text-xs px-2 py-1 rounded font-mono opacity-70">
<div id="inspect-screen">screen: list</div>
<div id="inspect-action" class="hidden"></div>
</div>
<!-- CONTACT MODAL: quick create -->
<dialog id="contact-modal" class="modal">
<div class="modal-box bg-base-100 max-w-md">
<h3 class="font-bold text-sm mb-2" data-i18n="contact_modal.title">New contact</h3>
<div id="contact-modal-form" class="space-y-3"></div>
<div class="modal-action">
<button class="btn btn-ghost btn-sm" data-action="contact-modal-close" data-i18n="app.cancel">Cancel</button>
<button class="btn btn-primary btn-sm" data-action="contact-modal-save" data-i18n="app.save">Save</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- DELETE MODAL -->
<dialog id="delete-modal" class="modal">
<div class="modal-box bg-base-100 max-w-md">
<h3 class="font-bold text-lg text-error" data-i18n="delete.confirm_title">Delete this contact?</h3>
<p class="py-4 text-sm opacity-70"><span data-i18n="delete.confirm_desc">This contact will be permanently deleted.</span><br /><strong id="delete-modal-name" class="break-words"></strong></p>
<div class="modal-action">
<button class="btn btn-ghost" data-action="delete-cancel" data-i18n="app.cancel">Cancel</button>
<button class="btn btn-error" data-action="delete-confirm" data-i18n="delete.confirm_btn">Delete</button>
</div>
</div>
</dialog>
<!-- MERGE CONFIRM MODAL -->
<dialog id="merge-confirm-modal" class="modal">
<div class="modal-box bg-base-100 max-w-md">
<h3 class="font-bold text-lg" data-i18n="merge.confirm_title">Confirm merge?</h3>
<p class="py-4 text-sm opacity-70" id="merge-confirm-desc"></p>
<div class="modal-action">
<button class="btn btn-ghost" data-action="merge-cancel" data-i18n="app.cancel">Cancel</button>
<button class="btn btn-primary" data-action="merge-confirm" data-i18n="app.confirm">Confirm</button>
</div>
</div>
</dialog>
<script src="/cdn/dev/lib/apxtri/menu.js"></script>
<script src="/cdn/dev/lib/apxtri/notify.js"></script>
<script src="js/app.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "My Contacts",
"short_name": "MyContacts",
"lang": "en",
"start_url": "./",
"display": "standalone",
"orientation": "any",
"theme_color": "#3b82f6",
"background_color": "#ffffff",
"icons": [{
"src": "/cdn/dev/static/img/logo/logoX.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any maskable"
}]
}
+71
View File
@@ -0,0 +1,71 @@
{
"app.title": "My Contacts - apxtri",
"app.back": "Back",
"app.save": "Save",
"app.cancel": "Cancel",
"app.confirm": "Confirm",
"drawer.screen_list": "My Contacts",
"drawer.screen_duplicates": "Duplicates",
"list.search_placeholder": "Search name, email, phone...",
"list.kind_all": "All",
"list.empty_title": "No contacts yet",
"list.empty_subtitle": "Create your first contact with the + button",
"list.count": "{{n}} contact(s)",
"list.duplicates_badge": "Review {{n}} duplicate(s)",
"list.pick_tribe": "Pick a tribe to manage its contacts (druid only)",
"list.loading": "Loading contacts...",
"list.failed": "Failed to load contacts",
"detail.title": "Contact",
"detail.created_title": "New contact",
"detail.kind_label": "Kind",
"detail.kind_individual": "Individual",
"detail.kind_org": "Organization",
"detail.name_label": "Display name",
"detail.name_placeholder": "Jane Doe",
"detail.emails_label": "Emails (one per line)",
"detail.emails_hint": "First address is the preferred one",
"detail.phones_label": "Phones (one per line)",
"detail.phones_hint": "Digits, spaces and + allowed",
"detail.org_label": "Organization / role (one per line)",
"detail.org_hint": "e.g. Acme — Sales — Manager",
"detail.address_label": "Address (one per line)",
"detail.online_label": "Online accounts (URI, one per line)",
"detail.categories_label": "Categories (comma separated)",
"detail.notes_label": "Notes",
"detail.photo_label": "Photo URI",
"detail.delete": "Delete",
"detail.required": "Please fill at least a name, an email or a phone",
"detail.saved": "Contact saved",
"detail.save_error": "Error saving contact",
"detail.deleted": "Contact deleted",
"detail.delete_error": "Error deleting contact",
"detail.load_error": "Contact not found",
"contact_modal.title": "New contact",
"delete.confirm_title": "Delete this contact?",
"delete.confirm_desc": "This contact will be permanently deleted.",
"delete.confirm_btn": "Delete",
"duplicates.title": "Possible duplicates",
"duplicates.subtitle": "Pairs sharing an email, a phone or the exact same name.",
"duplicates.empty": "No duplicates found",
"duplicates.failed": "Failed to load duplicates",
"duplicates.merge": "Merge",
"duplicates.dismiss": "Dismiss",
"duplicates.keep_both": "Keep both",
"duplicates.score": "score {{n}}",
"duplicates.dismissed": "Pair dismissed",
"duplicates.dismiss_error": "Error dismissing pair",
"merge.title": "Merge contacts",
"merge.subtitle": "Pick the surviving contact, then choose which conflicting fields to merge.",
"merge.survivor": "Survivor",
"merge.field_merge": "Merge",
"merge.field_keep": "Keep survivor's",
"merge.submit": "Merge",
"merge.default_union": "Default union (all fields)",
"merge.no_conflict": "No conflicting fields — default union applies.",
"merge.confirm_title": "Confirm merge?",
"merge.confirm_desc": "Merge {{drop}} into {{keep}}? {{drop}} will be deleted.",
"merge.success": "Contacts merged",
"merge.error": "Error merging contacts",
"notify.lang_unavailable": "Language \"{{lg}}\" not available",
"notify.auth_required": "You must be authenticated"
}
+92
View File
@@ -0,0 +1,92 @@
{
"app.title": "My Contacts - apxtri",
"app.back": "Back",
"app.save": "Save",
"app.cancel": "Cancel",
"app.confirm": "Confirm",
"drawer.screen_list": "My Contacts",
"drawer.screen_duplicates": "Duplicates",
"list.search_placeholder": "Search name, email, phone...",
"list.title": "Contacts",
"list.new": "+ New",
"list.change_tribe": "Change tribe",
"list.kind_all": "All",
"list.empty_title": "No contacts yet",
"list.empty_subtitle": "Create your first contact with the + button",
"list.count": "{{n}} contact(s)",
"list.duplicates_badge": "Review {{n}} duplicate(s)",
"list.pick_tribe": "Pick a tribe to manage its contacts (druid only)",
"list.loading": "Loading contacts...",
"list.failed": "Failed to load contacts",
"detail.title": "Contact",
"detail.created_title": "New contact",
"detail.kind_label": "Kind",
"detail.kind_individual": "Individual",
"detail.kind_org": "Organization",
"detail.name_label": "Display name",
"detail.name_placeholder": "Jane Doe",
"detail.name_placeholder_person": "Genre | Prenom | Nom",
"detail.name_placeholder_org": "Entreprise",
"detail.emails_label": "Emails (one per line)",
"detail.emails_hint": "First address is the preferred one",
"detail.phones_label": "Phones (one per line)",
"detail.phones_hint": "Digits, spaces and + allowed",
"detail.org_label": "Organization / role (one per line)",
"detail.org_hint": "e.g. Acme — Sales — Manager",
"detail.address_label": "Address (one per line)",
"detail.online_label": "Online accounts (URI, one per line)",
"detail.categories_label": "Categories (comma separated)",
"detail.notes_label": "Notes",
"detail.photo_label": "Photo URI",
"detail.email_invalid": "Email invalide",
"detail.siret_invalid": "SIRET invalide (14 chiffres requis)",
"detail.tva_invalid": "TVA invalide",
"detail.siret_label": "SIRET",
"detail.tva_label": "TVA",
"detail.chorus_label": "Chorus",
"detail.org_block": "SIRET / TVA / Chorus",
"detail.add": "+",
"detail.cat_client": "client",
"detail.cat_gardien": "gardien",
"detail.cat_locataire": "locataire",
"detail.cat_proprietaire": "proprietaire",
"detail.cat_bailleur": "bailleur",
"detail.cat_agenceImmo": "agenceImmo",
"detail.cat_fournisseur": "fournisseur",
"detail.cat_prospect": "prospect",
"detail.delete": "Delete",
"detail.required": "Please fill at least a name, an email or a phone",
"detail.saved": "Contact saved",
"detail.save_error": "Error saving contact",
"detail.deleted": "Contact deleted",
"detail.delete_error": "Error deleting contact",
"detail.load_error": "Contact not found",
"contact_modal.title": "New contact",
"delete.confirm_title": "Delete this contact?",
"delete.confirm_desc": "This contact will be permanently deleted.",
"delete.confirm_btn": "Delete",
"duplicates.title": "Possible duplicates",
"duplicates.subtitle": "Pairs sharing an email, a phone or the exact same name.",
"duplicates.empty": "No duplicates found",
"duplicates.failed": "Failed to load duplicates",
"duplicates.merge": "Merge",
"duplicates.dismiss": "Dismiss",
"duplicates.keep_both": "Keep both",
"duplicates.score": "score {{n}}",
"duplicates.dismissed": "Pair dismissed",
"duplicates.dismiss_error": "Error dismissing pair",
"merge.title": "Merge contacts",
"merge.subtitle": "Pick the surviving contact, then choose which conflicting fields to merge.",
"merge.survivor": "Survivor",
"merge.field_merge": "Merge",
"merge.field_keep": "Keep survivor's",
"merge.submit": "Merge",
"merge.default_union": "Default union (all fields)",
"merge.no_conflict": "No conflicting fields — default union applies.",
"merge.confirm_title": "Confirm merge?",
"merge.confirm_desc": "Merge {{drop}} into {{keep}}? {{drop}} will be deleted.",
"merge.success": "Contacts merged",
"merge.error": "Error merging contacts",
"notify.lang_unavailable": "Language \"{{lg}}\" not available",
"notify.auth_required": "You must be authenticated"
}
+71
View File
@@ -0,0 +1,71 @@
{
"app.title": "My Contacts - apxtri",
"app.back": "Back",
"app.save": "Save",
"app.cancel": "Cancel",
"app.confirm": "Confirm",
"drawer.screen_list": "My Contacts",
"drawer.screen_duplicates": "Duplicates",
"list.search_placeholder": "Search name, email, phone...",
"list.kind_all": "All",
"list.empty_title": "No contacts yet",
"list.empty_subtitle": "Create your first contact with the + button",
"list.count": "{{n}} contact(s)",
"list.duplicates_badge": "Review {{n}} duplicate(s)",
"list.pick_tribe": "Pick a tribe to manage its contacts (druid only)",
"list.loading": "Loading contacts...",
"list.failed": "Failed to load contacts",
"detail.title": "Contact",
"detail.created_title": "New contact",
"detail.kind_label": "Kind",
"detail.kind_individual": "Individual",
"detail.kind_org": "Organization",
"detail.name_label": "Display name",
"detail.name_placeholder": "Jane Doe",
"detail.emails_label": "Emails (one per line)",
"detail.emails_hint": "First address is the preferred one",
"detail.phones_label": "Phones (one per line)",
"detail.phones_hint": "Digits, spaces and + allowed",
"detail.org_label": "Organization / role (one per line)",
"detail.org_hint": "e.g. Acme — Sales — Manager",
"detail.address_label": "Address (one per line)",
"detail.online_label": "Online accounts (URI, one per line)",
"detail.categories_label": "Categories (comma separated)",
"detail.notes_label": "Notes",
"detail.photo_label": "Photo URI",
"detail.delete": "Delete",
"detail.required": "Please fill at least a name, an email or a phone",
"detail.saved": "Contact saved",
"detail.save_error": "Error saving contact",
"detail.deleted": "Contact deleted",
"detail.delete_error": "Error deleting contact",
"detail.load_error": "Contact not found",
"contact_modal.title": "New contact",
"delete.confirm_title": "Delete this contact?",
"delete.confirm_desc": "This contact will be permanently deleted.",
"delete.confirm_btn": "Delete",
"duplicates.title": "Possible duplicates",
"duplicates.subtitle": "Pairs sharing an email, a phone or the exact same name.",
"duplicates.empty": "No duplicates found",
"duplicates.failed": "Failed to load duplicates",
"duplicates.merge": "Merge",
"duplicates.dismiss": "Dismiss",
"duplicates.keep_both": "Keep both",
"duplicates.score": "score {{n}}",
"duplicates.dismissed": "Pair dismissed",
"duplicates.dismiss_error": "Error dismissing pair",
"merge.title": "Merge contacts",
"merge.subtitle": "Pick the surviving contact, then choose which conflicting fields to merge.",
"merge.survivor": "Survivor",
"merge.field_merge": "Merge",
"merge.field_keep": "Keep survivor's",
"merge.submit": "Merge",
"merge.default_union": "Default union (all fields)",
"merge.no_conflict": "No conflicting fields — default union applies.",
"merge.confirm_title": "Confirm merge?",
"merge.confirm_desc": "Merge {{drop}} into {{keep}}? {{drop}} will be deleted.",
"merge.success": "Contacts merged",
"merge.error": "Error merging contacts",
"notify.lang_unavailable": "Language \"{{lg}}\" not available",
"notify.auth_required": "You must be authenticated"
}
+92
View File
@@ -0,0 +1,92 @@
{
"app.title": "Mes Contacts - apxtri",
"app.back": "Retour",
"app.save": "Enregistrer",
"app.cancel": "Annuler",
"app.confirm": "Confirmer",
"drawer.screen_list": "Mes Contacts",
"drawer.screen_duplicates": "Doublons",
"list.search_placeholder": "Rechercher nom, email, téléphone...",
"list.title": "Contacts",
"list.new": "+ Nouveau",
"list.change_tribe": "Changer de tribu",
"list.kind_all": "Tous",
"list.empty_title": "Aucun contact",
"list.empty_subtitle": "Créez votre premier contact avec le bouton +",
"list.count": "{{n}} contact(s)",
"list.duplicates_badge": "Vérifier {{n}} doublon(s)",
"list.pick_tribe": "Choisissez une tribu pour gérer ses contacts (druid uniquement)",
"list.loading": "Chargement des contacts...",
"list.failed": "Échec du chargement des contacts",
"detail.title": "Contact",
"detail.created_title": "Nouveau contact",
"detail.kind_label": "Type",
"detail.kind_individual": "Individu",
"detail.kind_org": "Organisation",
"detail.name_label": "Nom affiché",
"detail.name_placeholder": "Jeanne Dupont",
"detail.name_placeholder_person": "Genre | Prenom | Nom",
"detail.name_placeholder_org": "Entreprise",
"detail.emails_label": "Emails (un par ligne)",
"detail.emails_hint": "La première adresse est la préférée",
"detail.phones_label": "Téléphones (un par ligne)",
"detail.phones_hint": "Chiffres, espaces et + autorisés",
"detail.org_label": "Organisation / rôle (un par ligne)",
"detail.org_hint": "ex. Acme — Ventes — Manager",
"detail.address_label": "Adresse (une par ligne)",
"detail.online_label": "Comptes en ligne (URI, un par ligne)",
"detail.categories_label": "Catégories (séparées par des virgules)",
"detail.notes_label": "Notes",
"detail.photo_label": "URI de la photo",
"detail.email_invalid": "Email invalide",
"detail.siret_invalid": "SIRET invalide (14 chiffres requis)",
"detail.tva_invalid": "TVA invalide",
"detail.siret_label": "SIRET",
"detail.tva_label": "TVA",
"detail.chorus_label": "Chorus",
"detail.org_block": "SIRET / TVA / Chorus",
"detail.add": "+",
"detail.cat_client": "client",
"detail.cat_gardien": "gardien",
"detail.cat_locataire": "locataire",
"detail.cat_proprietaire": "proprietaire",
"detail.cat_bailleur": "bailleur",
"detail.cat_agenceImmo": "agenceImmo",
"detail.cat_fournisseur": "fournisseur",
"detail.cat_prospect": "prospect",
"detail.delete": "Supprimer",
"detail.required": "Renseignez au moins un nom, un email ou un téléphone",
"detail.saved": "Contact enregistré",
"detail.save_error": "Erreur lors de l'enregistrement",
"detail.deleted": "Contact supprimé",
"detail.delete_error": "Erreur lors de la suppression",
"detail.load_error": "Contact introuvable",
"contact_modal.title": "Nouveau contact",
"delete.confirm_title": "Supprimer ce contact ?",
"delete.confirm_desc": "Ce contact sera définitivement supprimé.",
"delete.confirm_btn": "Supprimer",
"duplicates.title": "Doublons possibles",
"duplicates.subtitle": "Paires partageant un email, un téléphone ou exactement le même nom.",
"duplicates.empty": "Aucun doublon trouvé",
"duplicates.failed": "Échec du chargement des doublons",
"duplicates.merge": "Fusionner",
"duplicates.dismiss": "Ignorer",
"duplicates.keep_both": "Garder les deux",
"duplicates.score": "score {{n}}",
"duplicates.dismissed": "Paire ignorée",
"duplicates.dismiss_error": "Erreur lors de l'ignorance de la paire",
"merge.title": "Fusionner les contacts",
"merge.subtitle": "Choisissez le contact survivant, puis les champs en conflit à fusionner.",
"merge.survivor": "Survivant",
"merge.field_merge": "Fusionner",
"merge.field_keep": "Garder celle du survivant",
"merge.submit": "Fusionner",
"merge.default_union": "Union par défaut (tous les champs)",
"merge.no_conflict": "Aucun champ en conflit — l'union par défaut s'applique.",
"merge.confirm_title": "Confirmer la fusion ?",
"merge.confirm_desc": "Fusionner {{drop}} dans {{keep}} ? {{drop}} sera supprimé.",
"merge.success": "Contacts fusionnés",
"merge.error": "Erreur lors de la fusion",
"notify.lang_unavailable": "Langue \"{{lg}}\" indisponible",
"notify.auth_required": "Vous devez être authentifié"
}
+71
View File
@@ -0,0 +1,71 @@
{
"app.title": "My Contacts - apxtri",
"app.back": "Back",
"app.save": "Save",
"app.cancel": "Cancel",
"app.confirm": "Confirm",
"drawer.screen_list": "My Contacts",
"drawer.screen_duplicates": "Duplicates",
"list.search_placeholder": "Search name, email, phone...",
"list.kind_all": "All",
"list.empty_title": "No contacts yet",
"list.empty_subtitle": "Create your first contact with the + button",
"list.count": "{{n}} contact(s)",
"list.duplicates_badge": "Review {{n}} duplicate(s)",
"list.pick_tribe": "Pick a tribe to manage its contacts (druid only)",
"list.loading": "Loading contacts...",
"list.failed": "Failed to load contacts",
"detail.title": "Contact",
"detail.created_title": "New contact",
"detail.kind_label": "Kind",
"detail.kind_individual": "Individual",
"detail.kind_org": "Organization",
"detail.name_label": "Display name",
"detail.name_placeholder": "Jane Doe",
"detail.emails_label": "Emails (one per line)",
"detail.emails_hint": "First address is the preferred one",
"detail.phones_label": "Phones (one per line)",
"detail.phones_hint": "Digits, spaces and + allowed",
"detail.org_label": "Organization / role (one per line)",
"detail.org_hint": "e.g. Acme — Sales — Manager",
"detail.address_label": "Address (one per line)",
"detail.online_label": "Online accounts (URI, one per line)",
"detail.categories_label": "Categories (comma separated)",
"detail.notes_label": "Notes",
"detail.photo_label": "Photo URI",
"detail.delete": "Delete",
"detail.required": "Please fill at least a name, an email or a phone",
"detail.saved": "Contact saved",
"detail.save_error": "Error saving contact",
"detail.deleted": "Contact deleted",
"detail.delete_error": "Error deleting contact",
"detail.load_error": "Contact not found",
"contact_modal.title": "New contact",
"delete.confirm_title": "Delete this contact?",
"delete.confirm_desc": "This contact will be permanently deleted.",
"delete.confirm_btn": "Delete",
"duplicates.title": "Possible duplicates",
"duplicates.subtitle": "Pairs sharing an email, a phone or the exact same name.",
"duplicates.empty": "No duplicates found",
"duplicates.failed": "Failed to load duplicates",
"duplicates.merge": "Merge",
"duplicates.dismiss": "Dismiss",
"duplicates.keep_both": "Keep both",
"duplicates.score": "score {{n}}",
"duplicates.dismissed": "Pair dismissed",
"duplicates.dismiss_error": "Error dismissing pair",
"merge.title": "Merge contacts",
"merge.subtitle": "Pick the surviving contact, then choose which conflicting fields to merge.",
"merge.survivor": "Survivor",
"merge.field_merge": "Merge",
"merge.field_keep": "Keep survivor's",
"merge.submit": "Merge",
"merge.default_union": "Default union (all fields)",
"merge.no_conflict": "No conflicting fields — default union applies.",
"merge.confirm_title": "Confirm merge?",
"merge.confirm_desc": "Merge {{drop}} into {{keep}}? {{drop}} will be deleted.",
"merge.success": "Contacts merged",
"merge.error": "Error merging contacts",
"notify.lang_unavailable": "Language \"{{lg}}\" not available",
"notify.auth_required": "You must be authenticated"
}
+71
View File
@@ -0,0 +1,71 @@
{
"app.title": "My Contacts - apxtri",
"app.back": "Back",
"app.save": "Save",
"app.cancel": "Cancel",
"app.confirm": "Confirm",
"drawer.screen_list": "My Contacts",
"drawer.screen_duplicates": "Duplicates",
"list.search_placeholder": "Search name, email, phone...",
"list.kind_all": "All",
"list.empty_title": "No contacts yet",
"list.empty_subtitle": "Create your first contact with the + button",
"list.count": "{{n}} contact(s)",
"list.duplicates_badge": "Review {{n}} duplicate(s)",
"list.pick_tribe": "Pick a tribe to manage its contacts (druid only)",
"list.loading": "Loading contacts...",
"list.failed": "Failed to load contacts",
"detail.title": "Contact",
"detail.created_title": "New contact",
"detail.kind_label": "Kind",
"detail.kind_individual": "Individual",
"detail.kind_org": "Organization",
"detail.name_label": "Display name",
"detail.name_placeholder": "Jane Doe",
"detail.emails_label": "Emails (one per line)",
"detail.emails_hint": "First address is the preferred one",
"detail.phones_label": "Phones (one per line)",
"detail.phones_hint": "Digits, spaces and + allowed",
"detail.org_label": "Organization / role (one per line)",
"detail.org_hint": "e.g. Acme — Sales — Manager",
"detail.address_label": "Address (one per line)",
"detail.online_label": "Online accounts (URI, one per line)",
"detail.categories_label": "Categories (comma separated)",
"detail.notes_label": "Notes",
"detail.photo_label": "Photo URI",
"detail.delete": "Delete",
"detail.required": "Please fill at least a name, an email or a phone",
"detail.saved": "Contact saved",
"detail.save_error": "Error saving contact",
"detail.deleted": "Contact deleted",
"detail.delete_error": "Error deleting contact",
"detail.load_error": "Contact not found",
"contact_modal.title": "New contact",
"delete.confirm_title": "Delete this contact?",
"delete.confirm_desc": "This contact will be permanently deleted.",
"delete.confirm_btn": "Delete",
"duplicates.title": "Possible duplicates",
"duplicates.subtitle": "Pairs sharing an email, a phone or the exact same name.",
"duplicates.empty": "No duplicates found",
"duplicates.failed": "Failed to load duplicates",
"duplicates.merge": "Merge",
"duplicates.dismiss": "Dismiss",
"duplicates.keep_both": "Keep both",
"duplicates.score": "score {{n}}",
"duplicates.dismissed": "Pair dismissed",
"duplicates.dismiss_error": "Error dismissing pair",
"merge.title": "Merge contacts",
"merge.subtitle": "Pick the surviving contact, then choose which conflicting fields to merge.",
"merge.survivor": "Survivor",
"merge.field_merge": "Merge",
"merge.field_keep": "Keep survivor's",
"merge.submit": "Merge",
"merge.default_union": "Default union (all fields)",
"merge.no_conflict": "No conflicting fields — default union applies.",
"merge.confirm_title": "Confirm merge?",
"merge.confirm_desc": "Merge {{drop}} into {{keep}}? {{drop}} will be deleted.",
"merge.success": "Contacts merged",
"merge.error": "Error merging contacts",
"notify.lang_unavailable": "Language \"{{lg}}\" not available",
"notify.auth_required": "You must be authenticated"
}
+24
View File
@@ -0,0 +1,24 @@
{
"burger": {
"items": []
},
"title": "My Contacts",
"avatar": [
{
"id": "drawer-list",
"label": "My Contacts"
},
{
"id": "drawer-duplicates",
"label": "Duplicates"
}
],
"langs": [
"en",
"fr",
"de",
"es",
"it",
"nl"
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"burger": {
"items": []
},
"title": "My Contacts",
"avatar": [
{
"id": "drawer-list",
"label": "My Contacts"
},
{
"id": "drawer-duplicates",
"label": "Duplicates"
}
],
"langs": [
"en",
"fr",
"de",
"es",
"it",
"nl"
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"burger": {
"items": []
},
"title": "My Contacts",
"avatar": [
{
"id": "drawer-list",
"label": "My Contacts"
},
{
"id": "drawer-duplicates",
"label": "Duplicates"
}
],
"langs": [
"en",
"fr",
"de",
"es",
"it",
"nl"
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"burger": {
"items": []
},
"title": "My Contacts",
"avatar": [
{
"id": "drawer-list",
"label": "Mes contacts"
},
{
"id": "drawer-duplicates",
"label": "Doublons"
}
],
"langs": [
"en",
"fr",
"de",
"es",
"it",
"nl"
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"burger": {
"items": []
},
"title": "My Contacts",
"avatar": [
{
"id": "drawer-list",
"label": "My Contacts"
},
{
"id": "drawer-duplicates",
"label": "Duplicates"
}
],
"langs": [
"en",
"fr",
"de",
"es",
"it",
"nl"
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"burger": {
"items": []
},
"title": "My Contacts",
"avatar": [
{
"id": "drawer-list",
"label": "My Contacts"
},
{
"id": "drawer-duplicates",
"label": "Duplicates"
}
],
"langs": [
"en",
"fr",
"de",
"es",
"it",
"nl"
]
}
+54
View File
@@ -0,0 +1,54 @@
const CACHE_NAME = 'mycontacts-cache-v1';
const PRECACHE_ASSETS = [
'./',
'index.html',
'css/output.css',
'js/app.js',
'manifest.json'
];
self.addEventListener('install', function (event) {
event.waitUntil(
Promise.allSettled(
PRECACHE_ASSETS.map(function (url) {
return caches.open(CACHE_NAME).then(function (cache) {
return fetch(url, { cache: 'no-cache' }).then(function (response) {
if (response.ok) cache.put(url, response.clone());
return response;
}).catch(function () {
return cache.match(url);
});
});
})
).then(function () { return self.skipWaiting(); })
);
});
self.addEventListener('fetch', function (event) {
if (event.request.method !== 'GET') return;
event.respondWith(
(async function () {
try {
var networkResponse = await fetch(event.request);
if (networkResponse.ok) {
var cache = await caches.open(CACHE_NAME);
cache.put(event.request, networkResponse.clone());
}
return networkResponse;
} catch (err) {
var cachedResponse = await caches.match(event.request);
return cachedResponse || new Response('Offline', { status: 503 });
}
})()
);
});
self.addEventListener('activate', function (event) {
event.waitUntil(
caches.keys().then(function (cacheNames) {
return Promise.all(
cacheNames.filter(function (name) { return name !== CACHE_NAME; }).map(function (name) { return caches.delete(name); })
);
}).then(function () { return self.clients.claim(); })
);
});
+4 -4
View File
@@ -15,7 +15,7 @@
<script src="/cdn/dev/lib/axios/dist/axios.min.js"></script>
<script src="/cdn/dev/lib/openpgp/dist/openpgp.min.js"></script>
<script src="/cdn/dev/lib/dayjs/dayjs.min.js"></script>
<script src="/cdn/dev/lib/apxtri/draft.js"></script>
<script src="/cdn/dev/lib/apxtri/inspect.js"></script>
<script src="/cdn/dev/lib/apxtri/auth.js"></script>
<script src="/cdn/dev/lib/apxtri/localdb.js"></script>
<link href="/cdn/dev/css/base.css" rel="stylesheet" /><link href="/cdn/dev/css/mydev.css" rel="stylesheet" />
@@ -95,9 +95,9 @@
</div>
</div>
<div id="draft-debug" class="hidden fixed bottom-2 right-2 z-50 bg-neutral text-neutral-content text-xs px-2 py-1 rounded font-mono opacity-70">
<div id="draft-screen">screen: home</div>
<div id="draft-action" class="hidden"></div>
<div id="inspect-debug" class="hidden fixed bottom-2 right-2 z-50 bg-neutral text-neutral-content text-xs px-2 py-1 rounded font-mono opacity-70">
<div id="inspect-screen">screen: home</div>
<div id="inspect-action" class="hidden"></div>
</div>
<!-- INFO MODAL: field explanations -->
+1 -22
View File
@@ -5,7 +5,7 @@
* complete, copy-paste ready step-by-step install guide - so nothing is forgotten.
* BUILD: formatted with prettier for readability; minified by buildpwa (terser) for production.
*
* DRAFT MODE: screen badge + hover data-action.
* INSPECT MODE: screen badge + hover data-action.
* ALL selectors use [data-action]; every clickable element has data-action attribute.
*/
const APP_TRIBE = document.querySelector('meta[name="xtribe"]')?.content || 'dev';
@@ -1045,27 +1045,6 @@ var App = {
if (e.target.closest('[data-action="info-close"]')) App.closeInfo();
});
}
// Draft mode hover debug
if (typeof IS_DRAFT !== 'undefined' && IS_DRAFT) {
document.getElementById('draft-debug')?.classList.remove('hidden');
document.body.addEventListener('mouseover', function (e) {
var t = e.target.closest('button,a,label,input,[data-action]');
if (t) {
var a = document.getElementById('draft-action');
if (a) {
var action = t.dataset ? t.dataset.action : null;
if (action) {
a.textContent = 'data-action:' + action;
a.classList.remove('hidden');
}
}
} else {
var d = document.getElementById('draft-action');
d && d.classList.add('hidden');
}
});
}
}
};
-3
View File
@@ -26,9 +26,6 @@ self.addEventListener('install', function (event) {
self.addEventListener('fetch', function (event) {
if (event.request.method !== 'GET') return;
if (event.request.url.indexOf('/draft/') !== -1) {
return event.respondWith(fetch(event.request));
}
event.respondWith(
(async function () {
try {
+37 -32
View File
@@ -84,19 +84,19 @@
<input type="hidden" id="f-uid" />
<div class="space-y-2 text-sm">
<label class="label py-0"><span class="label-text" data-i18n="form.summary">Summary</span></label>
<input id="f-summary" class="input input-bordered w-full input-sm" required />
<input id="f-summary" data-schema="events.summary" class="input input-bordered w-full input-sm" required />
<label class="label py-0"><span class="label-text" data-i18n="form.description">Description</span></label>
<textarea id="f-description" class="textarea textarea-bordered w-full textarea-sm" rows="3"></textarea>
<textarea id="f-description" data-schema="events.description" class="textarea textarea-bordered w-full textarea-sm" rows="3"></textarea>
<div class="grid grid-cols-2 gap-2">
<div><label class="label py-0"><span class="label-text" data-i18n="form.dtstart">Start</span></label>
<input id="f-dtstart" type="datetime-local" class="input input-bordered w-full input-sm" /></div>
<input id="f-dtstart" data-schema="events.dtstart" type="datetime-local" class="input input-bordered w-full input-sm" /></div>
<div><label class="label py-0"><span class="label-text" data-i18n="form.dtend">End</span></label>
<input id="f-dtend" type="datetime-local" class="input input-bordered w-full input-sm" /></div>
<input id="f-dtend" data-schema="events.dtend" type="datetime-local" class="input input-bordered w-full input-sm" /></div>
</div>
<label class="label cursor-pointer justify-start gap-2"><input id="f-allday" type="checkbox" class="checkbox checkbox-xs" /> <span class="label-text" data-i18n="form.allday">All day</span></label>
<label class="label cursor-pointer justify-start gap-2"><input id="f-allday" data-schema="events.allDay" type="checkbox" class="checkbox checkbox-xs" /> <span class="label-text" data-i18n="form.allday">All day</span></label>
<div>
<label class="label py-0"><span class="label-text" data-i18n="form.mode">Type</span></label>
<select id="f-mode" class="select select-bordered select-sm w-full">
<select id="f-mode" data-schema="events.mode" class="select select-bordered select-sm w-full">
<option value="physical" data-i18n="mode.physical">In person</option>
<option value="visio" data-i18n="mode.visio">Video call</option>
<option value="action" data-i18n="mode.action">Action</option>
@@ -104,13 +104,13 @@
</div>
<div id="loc-visio" class="hidden">
<label class="label py-0"><span class="label-text" data-i18n="form.link">Video call link</span></label>
<input id="f-link" class="input input-bordered w-full input-sm" type="url" placeholder="https://..." />
<input id="f-link" data-schema="events.link" class="input input-bordered w-full input-sm" type="url" placeholder="https://..." />
</div>
<div id="loc-physical">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="label py-0"><span class="label-text" data-i18n="form.country">Country</span></label>
<select id="f-country" class="select select-bordered select-sm w-full">
<select id="f-country" data-schema="events.location" class="select select-bordered select-sm w-full">
<option value="FR">France</option>
<option value="BE">Belgique</option>
<option value="CH">Suisse</option>
@@ -130,18 +130,18 @@
</div>
<div>
<label class="label py-0"><span class="label-text" data-i18n="form.location_name">Address</span></label>
<input id="f-locname" list="loc-suggestions" autocomplete="off" class="input input-bordered w-full input-sm" />
<input id="f-locname" data-schema="events.location" list="loc-suggestions" autocomplete="off" class="input input-bordered w-full input-sm" />
<datalist id="loc-suggestions"></datalist>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="grid grid-cols-2 gap-2" data-schema="events.location">
<input id="f-lat" class="input input-bordered input-sm" placeholder="lat" type="number" step="any" />
<input id="f-lng" class="input input-bordered input-sm" placeholder="lng" type="number" step="any" />
</div>
</div>
<div>
<label class="label py-0"><span class="label-text" data-i18n="form.visibility">Visibility for shared viewers</span></label>
<select id="f-visibility" class="select select-bordered select-sm w-full">
<select id="f-visibility" data-schema="events.visibility" class="select select-bordered select-sm w-full">
<option value="full" data-i18n="vis.full">Full content</option>
<option value="title" data-i18n="vis.title">Title only</option>
<option value="busy" data-i18n="vis.busy">Busy</option>
@@ -151,31 +151,31 @@
<label class="label py-0"><span class="label-text" data-i18n="form.sharedwith">Shared with (one per line)</span></label>
<button class="btn btn-xs btn-ghost" data-action="ev-share-picker"><i class="bi bi-people"></i> <span data-i18n="share.open">With</span></button>
</div>
<textarea id="f-shared" class="textarea textarea-bordered w-full textarea-sm" rows="2" placeholder="alias:phil&#10;profils:majors&#10;druids&#10;public"></textarea>
<textarea id="f-shared" data-schema="events.sharedWith" class="textarea textarea-bordered w-full textarea-sm" rows="2" placeholder="alias:phil&#10;profils:majors&#10;druids&#10;public"></textarea>
<div class="flex items-center justify-between">
<label class="label py-0"><span class="label-text" data-i18n="form.files">Linked files (one per line)</span></label>
<button class="btn btn-xs btn-ghost" data-action="files-browse"><i class="bi bi-folder2-open"></i> <span data-i18n="form.browse">Browse</span></button>
</div>
<textarea id="f-files" class="textarea textarea-bordered w-full textarea-sm" rows="2"></textarea>
<textarea id="f-files" data-schema="events.files" class="textarea textarea-bordered w-full textarea-sm" rows="2"></textarea>
<div class="divider my-1 text-xs" data-i18n="form.reservation_section">Reservation (APX3)</div>
<label class="label cursor-pointer justify-start gap-2"><input id="f-reservable" type="checkbox" class="checkbox checkbox-xs" /> <span class="label-text" data-i18n="form.reservable">Reservable slots</span></label>
<label class="label cursor-pointer justify-start gap-2"><input id="f-reservable" data-schema="events.reservable" type="checkbox" class="checkbox checkbox-xs" /> <span class="label-text" data-i18n="form.reservable">Reservable slots</span></label>
<div class="grid grid-cols-2 gap-2">
<div><label class="label py-0"><span class="label-text" data-i18n="form.fee">Fee (APX3)</span></label>
<input id="f-fee" type="number" step="0.01" min="0" class="input input-bordered w-full input-sm" /></div>
<input id="f-fee" data-schema="events.fee" type="number" step="0.01" min="0" class="input input-bordered w-full input-sm" /></div>
</div>
<div class="divider my-1 text-xs" data-i18n="form.invitation_section">Invitation (places)</div>
<div class="grid grid-cols-2 gap-2">
<div><label class="label py-0"><span class="label-text" data-i18n="form.capacity">Capacity (0 = unlimited)</span></label>
<input id="f-capacity" type="number" min="0" step="1" class="input input-bordered w-full input-sm" value="0" /></div>
<input id="f-capacity" data-schema="events.capacity" type="number" min="0" step="1" class="input input-bordered w-full input-sm" value="0" /></div>
<div><label class="label py-0"><span class="label-text" data-i18n="form.registered">Registered</span></label>
<input id="f-registered" type="text" class="input input-bordered w-full input-sm" value="0" disabled /></div>
<input id="f-registered" data-schema="events.registrations" type="text" class="input input-bordered w-full input-sm" value="0" disabled /></div>
</div>
<label class="label py-0"><span class="label-text" data-i18n="form.slots">Slots JSON (optional)</span></label>
<textarea id="f-slots" class="textarea textarea-bordered w-full textarea-sm font-mono" rows="3" placeholder='[{"start":"2026-09-01T09:00:00Z","end":"2026-09-01T10:00:00Z"}]'></textarea>
<textarea id="f-slots" data-schema="events.slots" class="textarea textarea-bordered w-full textarea-sm font-mono" rows="3" placeholder='[{"start":"2026-09-01T09:00:00Z","end":"2026-09-01T10:00:00Z"}]'></textarea>
<div class="divider my-1 text-xs" data-i18n="form.script_section">Script</div>
<div class="grid grid-cols-1 gap-2">
<input id="f-scriptname" class="input input-bordered w-full input-sm" placeholder="script name" />
<textarea id="f-scriptparams" class="textarea textarea-bordered w-full textarea-sm font-mono" rows="2" placeholder='{"key":"value"}'></textarea>
<input id="f-scriptname" data-schema="events.script" class="input input-bordered w-full input-sm" placeholder="script name" />
<textarea id="f-scriptparams" data-schema="events.script" class="textarea textarea-bordered w-full textarea-sm font-mono" rows="2" placeholder='{"key":"value"}'></textarea>
</div>
</div>
<div class="modal-action">
@@ -188,7 +188,7 @@
<!-- Event detail modal -->
<dialog id="ev-detail" class="modal">
<div class="modal-box bg-base-100 max-w-md">
<h3 class="font-bold text-sm mb-2" id="ev-detail-title"></h3>
<h3 class="font-bold text-sm mb-2" id="ev-detail-title" data-schema="events.summary"></h3>
<div id="ev-detail-body" class="text-sm space-y-1"></div>
<div class="modal-action flex-wrap">
<button class="btn btn-ghost btn-sm" data-action="ev-detail-close" data-i18n="app.close">Close</button>
@@ -206,9 +206,9 @@
<h3 class="font-bold text-sm mb-3" data-i18n="settings.title">Agenda settings</h3>
<div class="space-y-2 text-sm">
<label class="label py-0"><span class="label-text" data-i18n="settings.sharedwith">Globally shared with (one per line)</span></label>
<textarea id="s-shared" class="textarea textarea-bordered w-full textarea-sm" rows="2" placeholder="alias:phil&#10;profils:majors&#10;druids&#10;public"></textarea>
<textarea id="s-shared" data-schema="agendasettings.sharedWith" class="textarea textarea-bordered w-full textarea-sm" rows="2" placeholder="alias:phil&#10;profils:majors&#10;druids&#10;public"></textarea>
<label class="label py-0"><span class="label-text" data-i18n="settings.defaultvis">Default visibility (when an event has none)</span></label>
<select id="s-vis" class="select select-bordered select-sm w-full">
<select id="s-vis" data-schema="agendasettings.defaultVisibility" class="select select-bordered select-sm w-full">
<option value="" data-i18n="settings.vis_default">Default (full)</option>
<option value="full" data-i18n="vis.full">Full content</option>
<option value="title" data-i18n="vis.title">Title only</option>
@@ -216,21 +216,21 @@
</select>
<div class="grid grid-cols-2 gap-2">
<div><label class="label py-0"><span class="label-text" data-i18n="settings.workstart">Work start (free)</span></label>
<input id="s-workstart" type="time" class="input input-bordered w-full input-sm" /></div>
<input id="s-workstart" data-schema="agendasettings.workStart" type="time" class="input input-bordered w-full input-sm" /></div>
<div><label class="label py-0"><span class="label-text" data-i18n="settings.workend">Work end (free)</span></label>
<input id="s-workend" type="time" class="input input-bordered w-full input-sm" /></div>
<input id="s-workend" data-schema="agendasettings.workEnd" type="time" class="input input-bordered w-full input-sm" /></div>
</div>
<label class="label py-0"><span class="label-text" data-i18n="settings.workdays">Work days (comma list, 1=Mon..7=Sun)</span></label>
<input id="s-workdays" class="input input-bordered w-full input-sm" placeholder="1,2,3,4,5" />
<label class="label cursor-pointer justify-start gap-2"><input id="s-showreservable" type="checkbox" class="checkbox checkbox-xs" /> <span class="label-text" data-i18n="settings.showreservable">Show reservable slots on shared calendars</span></label>
<input id="s-workdays" data-schema="agendasettings.workDays" class="input input-bordered w-full input-sm" placeholder="1,2,3,4,5" />
<label class="label cursor-pointer justify-start gap-2"><input id="s-showreservable" data-schema="agendasettings.showReservable" type="checkbox" class="checkbox checkbox-xs" /> <span class="label-text" data-i18n="settings.showreservable">Show reservable slots on shared calendars</span></label>
<div class="grid grid-cols-2 gap-2">
<div><label class="label py-0"><span class="label-text" data-i18n="settings.daystart">Week grid start</span></label>
<input id="s-daystart" type="time" class="input input-bordered w-full input-sm" /></div>
<input id="s-daystart" data-schema="agendasettings.dayStart" type="time" class="input input-bordered w-full input-sm" /></div>
<div><label class="label py-0"><span class="label-text" data-i18n="settings.dayend">Week grid end</span></label>
<input id="s-dayend" type="time" class="input input-bordered w-full input-sm" /></div>
<input id="s-dayend" data-schema="agendasettings.dayEnd" type="time" class="input input-bordered w-full input-sm" /></div>
</div>
<label class="label py-0"><span class="label-text" data-i18n="settings.slotminutes">Week grid row height</span></label>
<select id="s-slotminutes" class="select select-bordered select-sm w-full">
<select id="s-slotminutes" data-schema="agendasettings.slotMinutes" class="select select-bordered select-sm w-full">
<option value="15">15 min</option>
<option value="30">30 min</option>
<option value="60">1 h</option>
@@ -243,7 +243,7 @@
<option value="480">8 h</option>
</select>
<label class="label py-0"><span class="label-text" data-i18n="settings.minutestep">Event form minute step</span></label>
<select id="s-minutestep" class="select select-bordered select-sm w-full">
<select id="s-minutestep" data-schema="agendasettings.minuteStep" class="select select-bordered select-sm w-full">
<option value="5">5 min</option>
<option value="10">10 min</option>
<option value="15">15 min</option>
@@ -315,11 +315,16 @@
<script src="/cdn/dev/lib/openpgp/dist/openpgp.min.js"></script>
<script src="/cdn/dev/lib/dayjs/dayjs.min.js"></script>
<script src="/cdn/dev/lib/bip39/bip39.js"></script>
<script src="/cdn/dev/lib/apxtri/draft.js"></script>
<script src="/cdn/dev/lib/apxtri/inspect.js"></script>
<script src="/cdn/dev/lib/apxtri/auth.js"></script>
<script src="/cdn/dev/lib/apxtri/localdb.js"></script>
<script src="/cdn/dev/lib/apxtri/menu.js"></script>
<script src="/cdn/dev/lib/apxtri/notify.js"></script>
<div id="inspect-debug" class="hidden fixed bottom-2 right-2 z-50 bg-neutral text-neutral-content text-xs px-2 py-1 rounded font-mono opacity-70">
<div id="inspect-screen">screen: events</div>
<div id="inspect-action" class="hidden"></div>
</div>
<script src="js/app.js"></script>
</body>
</html>
+1 -12
View File
@@ -1,4 +1,4 @@
/* My Events - calendar subapp (IS_DRAFT and Log are provided by draft.js) */
/* My Events - calendar subapp (Log is provided by inspect.js) */
/* Tailwind class inventory for the shared menu.js drawer (scanned by Tailwind) */
var _menuCss = "drawer drawer-content drawer-side drawer-overlay drawer-toggle drawer-end menu p-4 w-72 min-h-full min-h-screen bg-base-200 text-base-content text-neutral-content bg-neutral flex flex-col space-y-1 space-y-3 mt-auto pt-3 pb-3 border-t border-b border-base-300 mb-4 gap-3 items-center justify-start justify-between justify-end flex-1 text-center sticky top-0 z-30 z-40 z-50 bg-base-100/90 backdrop-blur-sm px-4 py-3 btn btn-ghost btn-sm btn-xs btn-square btn-block btn-lang btn-active toggle toggle-primary avatar placeholder rounded-full w-8 h-8 w-10 h-10 font-bold text-sm text-xs opacity-50 cursor-pointer hidden";
var LG = {
@@ -132,16 +132,6 @@ var App = {
settings: { sharedWith: [], defaultVisibility: '', workStart: '09:00', workEnd: '17:00', workDays: [1,2,3,4,5], showReservable: true, dayStart: '08:00', dayEnd: '18:00', slotMinutes: 60, minuteStep: 15 },
// ---- init ----
_initDraftDebug: function () {
if (document.getElementById('draft-debug')) return;
var dd = document.createElement('div');
dd.id = 'draft-debug';
dd.style.cssText = 'display:none;position:fixed;bottom:8px;right:8px;z-index:50;background:#1f2937;color:#f3f4f6;font-size:11px;padding:4px 8px;border-radius:4px;font-family:monospace;opacity:0.7';
dd.innerHTML = '<div id="draft-screen">screen: events</div><div id="draft-action" style="display:none"></div>';
document.body.appendChild(dd);
dd.style.display = '';
},
async init() {
Auth.initTheme();
Auth.initAxios();
@@ -154,7 +144,6 @@ var App = {
await LG.set(lang);
try { await Menu.init(); } catch (e) {}
try { Menu.showScreen('events'); } catch (e) {}
if (IS_DRAFT) this._initDraftDebug();
var alias = Auth.getActiveAlias();
Log.debug('init alias=', alias);
this._bindEvents();
-1
View File
@@ -18,7 +18,6 @@ self.addEventListener('install', function (event) {
self.addEventListener('fetch', function (event) {
if (event.request.method !== 'GET') return;
if (event.request.url.indexOf('/draft/') !== -1) return event.respondWith(fetch(event.request));
event.respondWith(
(async function () {
try {
+14
View File
@@ -0,0 +1,14 @@
{
"name": "mymail",
"enabled": true,
"description": "Mailboxes PWA of the town mainapp (subapp)",
"security": {
"headers": {
"Content-Security-Policy": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self'; manifest-src 'self'; connect-src 'self'; frame-src 'self' blob:; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; worker-src 'self'",
"Referrer-Policy": "same-origin",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "SAMEORIGIN",
"Permissions-Policy": "accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), screen-wake-lock=(), usb=(), web-share=(), xr-spatial-tracking=()"
}
}
}
+212 -61
View File
@@ -14,11 +14,29 @@
<script src="/cdn/dev/lib/axios/dist/axios.min.js"></script>
<script src="/cdn/dev/lib/openpgp/dist/openpgp.min.js"></script>
<script src="/cdn/dev/lib/dayjs/dayjs.min.js"></script>
<script src="/cdn/dev/lib/apxtri/draft.js"></script>
<script src="/cdn/dev/lib/apxtri/inspect.js"></script>
<script src="/cdn/dev/lib/apxtri/auth.js"></script>
<script src="/cdn/dev/lib/apxtri/menu.js"></script>
<script src="/cdn/dev/lib/apxtri/plugins.js"></script>
<link href="/cdn/dev/css/base.css" rel="stylesheet" /><link href="/cdn/dev/css/mymail.css" rel="stylesheet" />
<style>
/* Responsive guard for the compose editor: its toolbar is a single non-wrapping row
and its editing area is a fixed-width iframe, both of which would widen the card
past the viewport on a phone. */
#compose-body-host, #compose-body-host .sun-editor { min-width: 0; max-width: 100%; }
#compose-body-host .se-container, #compose-body-host .se-toolbar-inner { max-width: 100%; }
#compose-body-host .se-toolbar { overflow-x: auto; }
/* Template explorer (screen-compose): raw <pre> blocks and the preview
iframe must never widen the card on a phone. */
#tpl-explorer pre { white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; max-width: 100%; }
#tpl-explorer ul, #tpl-explorer table { max-width: 100%; }
#tpl-preview-frame { width: 100%; min-height: 300px; max-width: 100%; }
@media (max-width: 640px) {
.label-text,.badge,.label,.card-body p,.card-body h3{white-space:normal!important;word-break:break-word;overflow-wrap:anywhere}
.card{overflow-wrap:anywhere}
input.input,textarea.textarea,select.select{min-width:0}
}
</style>
</head>
<body class="min-h-screen bg-base-200">
<div id="loading-overlay" class="fixed inset-0 bg-base-100 z-50 flex items-center justify-center transition-opacity duration-500">
@@ -55,27 +73,27 @@
<div class="card-body p-4">
<fieldset class="fieldset mb-2">
<label class="label py-1"><span class="text-xs" data-i18n="druid.provider">Provider</span></label>
<select id="druid-provider" class="select select-sm w-full">
<select id="druid-provider" data-schema="mailboxes.provider" class="select select-sm w-full">
<option value="imap" data-i18n="provider.imap">IMAP</option>
<option value="pop3" data-i18n="provider.pop3">POP3</option>
</select>
</fieldset>
<fieldset class="fieldset mb-2">
<label class="label py-1"><span class="text-xs" data-i18n="druid.email">Email / username</span></label>
<input id="druid-email" type="text" class="input input-sm w-full" placeholder="user@example.com" autocomplete="off" />
<input id="druid-email" data-schema="mailboxes.email" type="text" class="input input-sm w-full" placeholder="user@example.com" autocomplete="off" />
</fieldset>
<fieldset class="fieldset mb-2">
<label class="label py-1"><span class="text-xs" data-i18n="druid.label">Label</span></label>
<input id="druid-label" class="input input-sm w-full" placeholder="Contact" autocomplete="off" />
<input id="druid-label" data-schema="mailboxes.label" class="input input-sm w-full" placeholder="Contact" autocomplete="off" />
</fieldset>
<div class="grid grid-cols-2 gap-2">
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="druid.tribe">Tribe</span></label>
<select id="druid-new-tribe" class="select select-sm w-full"></select>
<select id="druid-new-tribe" data-schema="mailboxes.tribe" class="select select-sm w-full"></select>
</fieldset>
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="mailbox.poll.label">Poll interval (min)</span></label>
<input id="druid-pollinterval" type="number" min="1" value="5" class="input input-sm w-full" />
<input id="druid-pollinterval" data-schema="mailboxes.pollIntervalMin" type="number" min="1" value="5" class="input input-sm w-full" />
</fieldset>
</div>
@@ -86,34 +104,34 @@
<div class="grid grid-cols-2 gap-2 mb-2">
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="conn.host">Server host</span></label>
<input id="c-host" class="input input-sm w-full" placeholder="imap.example.com" autocomplete="off" />
<input id="c-host-imap" data-schema="mailboxes.connector" class="input input-sm w-full" placeholder="imap.example.com" autocomplete="off" />
</fieldset>
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="conn.port">Port</span></label>
<input id="c-port" type="number" class="input input-sm w-full" value="993" />
<input id="c-port-imap" data-schema="mailboxes.connector" type="number" class="input input-sm w-full" value="993" />
</fieldset>
</div>
<div class="grid grid-cols-2 gap-2 mb-2">
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="conn.folder">Folder</span></label>
<input id="c-folder" class="input input-sm w-full" value="INBOX" autocomplete="off" />
<input id="c-folder-imap" data-schema="mailboxes.connector" class="input input-sm w-full" value="INBOX" autocomplete="off" />
</fieldset>
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="conn.trash_folder">Trash folder</span></label>
<input id="c-trashfolder" class="input input-sm w-full" placeholder="INBOX.Trash" autocomplete="off" />
<input id="c-trashfolder-imap" data-schema="mailboxes.connector" class="input input-sm w-full" placeholder="INBOX.Trash" autocomplete="off" />
</fieldset>
</div>
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="conn.sent_folder">Sent folder (apxtriSent)</span></label>
<input id="c-sentfolder" class="input input-sm w-full" placeholder="auto ([Gmail]/Sent Mail for Gmail, Sent otherwise)" autocomplete="off" />
<input id="c-sentfolder-imap" data-schema="mailboxes.connector" class="input input-sm w-full" placeholder="auto ([Gmail]/Sent Mail for Gmail, Sent otherwise)" autocomplete="off" />
</fieldset>
<label class="label cursor-pointer justify-start gap-2 py-1 mb-1">
<input id="c-tls" type="checkbox" class="toggle toggle-primary toggle-sm" checked />
<input id="c-tls-imap" data-schema="mailboxes.connector" type="checkbox" class="toggle toggle-primary toggle-sm" checked />
<span class="text-[11px] opacity-70" data-i18n="conn.tls">Use TLS (SSL/STARTTLS)</span>
</label>
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="conn.password">Password</span></label>
<input id="c-password" type="password" class="input input-sm w-full" autocomplete="off" />
<input id="c-password-imap" type="password" class="input input-sm w-full" autocomplete="off" />
</fieldset>
</fieldset>
@@ -121,47 +139,50 @@
<div class="grid grid-cols-2 gap-2 mb-2">
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="conn.host">Server host</span></label>
<input id="c-host" class="input input-sm w-full" placeholder="pop.example.com" autocomplete="off" />
<input id="c-host-pop3" data-schema="mailboxes.connector" class="input input-sm w-full" placeholder="pop.example.com" autocomplete="off" />
</fieldset>
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="conn.port">Port</span></label>
<input id="c-port" type="number" class="input input-sm w-full" value="995" />
<input id="c-port-pop3" data-schema="mailboxes.connector" type="number" class="input input-sm w-full" value="995" />
</fieldset>
</div>
<label class="label cursor-pointer justify-start gap-2 py-1 mb-1">
<input id="c-tls" type="checkbox" class="toggle toggle-primary toggle-sm" checked />
<input id="c-tls-pop3" data-schema="mailboxes.connector" type="checkbox" class="toggle toggle-primary toggle-sm" checked />
<span class="text-[11px] opacity-70" data-i18n="conn.tls">Use TLS (SSL/STARTTLS)</span>
</label>
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="conn.password">Password</span></label>
<input id="c-password" type="password" class="input input-sm w-full" autocomplete="off" />
<input id="c-password-pop3" type="password" class="input input-sm w-full" autocomplete="off" />
</fieldset>
<label class="label cursor-pointer justify-start gap-2 py-1 mb-1">
<input id="c-deleteondl" type="checkbox" class="checkbox checkbox-primary checkbox-sm" />
<input id="c-deleteondl-pop3" data-schema="mailboxes.connector" type="checkbox" class="checkbox checkbox-primary checkbox-sm" />
<span class="text-[11px] opacity-70" data-i18n="conn.delete_on_download">Delete remote message after download (leave unchecked to keep on server)</span>
</label>
</fieldset>
<div class="divider my-1 text-[10px] opacity-50" data-i18n="conn.smtp">Outgoing SMTP (sending)</div>
<div class="grid grid-cols-[2fr_1fr_1fr_auto] gap-2 items-end mb-2">
<div class="grid grid-cols-2 gap-2 mb-2">
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="conn.smtp_host">SMTP host</span></label>
<input id="s-host" class="input input-sm w-full" autocomplete="off" />
<input id="s-host" data-schema="mailboxes.connector" class="input input-sm w-full" autocomplete="off" />
</fieldset>
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="conn.smtp_port">Port</span></label>
<input id="s-port" type="number" class="input input-sm w-full" value="465" />
<input id="s-port" data-schema="mailboxes.connector" type="number" class="input input-sm w-full" value="465" />
</fieldset>
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="conn.smtp_tls">TLS</span></label>
<input id="s-tls" type="checkbox" class="toggle toggle-primary toggle-sm mt-1" checked />
</fieldset>
<label class="label py-1 text-[10px] opacity-50" data-i18n="conn.smtp_optional">optional</label>
</div>
<label class="label cursor-pointer justify-start gap-2 py-1 mb-1">
<input id="s-tls" data-schema="mailboxes.connector" type="checkbox" class="toggle toggle-primary toggle-sm" checked />
<span class="text-[11px] opacity-70" data-i18n="conn.smtp_tls">TLS</span>
</label>
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="conn.smtp_password">SMTP password</span></label>
<input id="s-password" type="password" class="input input-sm w-full" autocomplete="off" />
</fieldset>
<fieldset class="fieldset mt-2">
<label class="label py-1"><span class="text-xs" data-i18n="mailbox.mode.label">Attachments</span></label>
<select id="druid-downloadmode" class="select select-sm w-full">
<select id="druid-downloadmode" data-schema="mailboxes.downloadMode" class="select select-sm w-full">
<option value="lazy" data-i18n="mailbox.mode.lazy">lazy (download on click)</option>
<option value="eager" data-i18n="mailbox.mode.eager">eager (download at sync)</option>
</select>
@@ -252,37 +273,59 @@
<div id="detail-empty" class="flex-1 flex items-center justify-center card bg-base-100/50">
<p class="text-sm opacity-40" data-i18n="mail.select_hint">Select a message to read it</p>
</div>
<div id="detail-card" class="card bg-base-100 shadow-xl hidden md:flex md:flex-col md:h-full md:min-h-0">
<div id="detail-card" class="card bg-base-100 shadow-xl hidden md:flex md:flex-col md:h-full md:min-h-0" style="display:none">
<div class="card-body p-4 flex-1 overflow-y-auto md:max-h-none">
<div class="flex items-center justify-between gap-2 mb-1">
<button class="btn btn-ghost btn-xs md:hidden" data-action="mail-detail-close">‹ <span data-i18n="mail.back">Back to list</span></button>
<div class="flex items-center gap-1 ml-auto">
<div id="plugin-emailaction" class="flex items-center gap-1 hidden"></div>
<button class="btn btn-ghost btn-xs btn-square text-error" data-action="mail-trash-one" data-i18n-title="mail.trash_one_btn" title="Delete mail">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
<span id="detail-status" class="badge badge-xs badge-ghost"></span>
<span id="detail-status" data-schema="mails.status" class="badge badge-xs badge-ghost shrink-0"></span>
<div id="detail-tags" data-schema="mails.tags" class="hidden flex flex-wrap items-center gap-1 min-w-0 max-w-[55%]">
<div id="detail-tags-list" class="flex flex-wrap items-center gap-1 min-w-0"></div>
</div>
<button class="btn btn-ghost btn-xs hidden md:inline-flex" data-action="mail-detail-close" title="Close">✕</button>
</div>
</div>
<h3 id="detail-subject" class="font-bold break-words">-</h3>
<div id="detail-actions" class="hidden flex flex-wrap gap-1 mb-2">
<button type="button" class="btn btn-xs btn-ghost btn-square" data-action="mail-compose-new" data-i18n-title="mail.compose" aria-label="Compose" title="Compose">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-mail-pen"><path d="M15.363 17.634a2 2 0 00-.506.854l-.837 2.87a.5.5 0 00.62.62l2.87-.837a2 2 0 00.854-.506l3.013-3.009a1 1 0 10-3.004-3.004z"/><path d="M22 10.38V6a2 2 0 00-2-2H4a2 2 0 00-2 2v12a2 2 0 002 2h6.25"/><path d="m22 7-8.991 5.727a2 2 0 01-2.009 0L2 7"/></svg>
</button>
<button type="button" class="btn btn-xs btn-ghost btn-square" data-action="mail-reply" data-i18n-title="mail.reply" aria-label="Reply" title="Reply">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-reply"><path d="M20 18v-2a4 4 0 0 0-4-4H4"/><path d="m9 17-5-5 5-5"/></svg>
</button>
<button type="button" class="btn btn-xs btn-ghost btn-square" data-action="mail-reply-all" data-i18n-title="mail.reply_all" aria-label="Reply all" title="Reply all">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-reply-all"><path d="m12 17-5-5 5-5"/><path d="M22 18v-2a4 4 0 0 0-4-4H7"/><path d="m7 17-5-5 5-5"/></svg>
</button>
<button type="button" class="btn btn-xs btn-ghost btn-square" data-action="mail-forward" data-i18n-title="mail.forward" aria-label="Forward" title="Forward">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-forward"><path d="m15 17 5-5-5-5"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/></svg>
</button>
<button type="button" class="btn btn-xs btn-ghost" data-action="mail-toggle-read" data-i18n-title="mail.toggle_read" aria-label="Toggle read/unread" title="Toggle read/unread">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"></path><circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
<button type="button" id="mail-trash-one" class="btn btn-xs btn-ghost btn-square text-error" data-action="mail-trash-one" data-i18n-title="mail.trash" aria-label="Trash" title="Trash">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</div>
<div id="detail-plugins" class="flex flex-wrap items-center gap-1 mb-2 border-t border-base-300/40 pt-1 bg-base-200/20 -mx-4 px-4">
<span id="detail-plugins-tribe" class="text-[11px] opacity-60 truncate min-w-0"></span>
<div class="ml-auto flex items-center gap-1 shrink-0">
<div class="relative">
<button id="detail-tag-add-btn" class="btn btn-ghost btn-xs btn-square hidden" data-action="mail-toggle-tag-dropdown" data-i18n-title="mail.add_tag" aria-label="Add a tag" title="Add a tag">+</button>
<div id="detail-tag-dropdown" class="hidden absolute z-50 bg-base-100 border border-base-300 rounded-lg shadow-lg p-1 max-h-[200px] overflow-auto right-0 top-full mt-1 min-w-[150px]"></div>
</div>
<div id="detail-plugins-slot" data-plugin-target="emailaction"></div>
</div>
</div>
<h3 id="detail-subject" data-schema="mails.subject" class="font-bold break-words">-</h3>
<div id="detail-meta" class="text-xs mt-1 space-y-0.5">
<div class="flex flex-wrap items-baseline gap-x-2">
<span id="detail-from" class="opacity-70 min-w-0 break-all">-</span>
<span id="detail-from" data-schema="mails.from" class="opacity-70 min-w-0 break-all">-</span>
<span id="detail-date" class="opacity-50"></span>
</div>
<p id="detail-to" class="opacity-60 line-clamp-5"></p>
<p id="detail-cc" class="opacity-60 line-clamp-5 hidden"></p>
<p id="detail-to" data-schema="mails.to" class="opacity-60 line-clamp-5"></p>
<p id="detail-cc" data-schema="mails.cc" class="opacity-60 line-clamp-5 hidden"></p>
</div>
<div id="detail-tags" class="flex flex-wrap items-center gap-1 my-2 hidden">
<div id="detail-tags-list" class="flex flex-wrap gap-1"></div>
<div class="relative ml-auto">
<button id="detail-tag-add-btn" class="btn btn-ghost btn-xs hidden" data-action="mail-toggle-tag-dropdown" title="Add tag">
<span>+</span> <span data-i18n="mail.tags_label">Tags</span>
</button>
<div id="detail-tag-dropdown" class="hidden absolute z-50 bg-base-100 border border-base-300 rounded-lg shadow-lg p-1 max-h-[200px] overflow-auto right-0 top-full mt-1 min-w-[150px]"></div>
</div>
</div>
<div id="detail-attachments" class="space-y-1 my-2"></div>
<div id="detail-tabs" class="hidden flex gap-1 my-1">
<button id="tab-message" class="btn btn-xs btn-primary" data-action="mail-tab-message" data-i18n="mail.tab.message">Message</button>
@@ -335,8 +378,8 @@
<div id="druid-tags-list" class="space-y-1 mb-3"></div>
<div class="grid grid-cols-[1fr_auto_1fr_auto] gap-2 items-end mb-2">
<input id="druid-tag-key" class="input input-sm w-full" placeholder="key (e.g. bdc)" autocomplete="off" />
<select id="druid-tag-color" class="select select-sm w-28">
<input id="druid-tag-key" data-schema="mailboxes.availableTags" class="input input-sm w-full" placeholder="key (e.g. bdc)" autocomplete="off" />
<select id="druid-tag-color" data-schema="mailboxes.availableTags" class="select select-sm w-28">
<option value="primary">Primary</option>
<option value="secondary">Secondary</option>
<option value="accent">Accent</option>
@@ -346,7 +389,7 @@
<option value="info">Info</option>
<option value="neutral">Neutral</option>
</select>
<input id="druid-tag-label" class="input input-sm w-full" placeholder="label (e.g. BDC créé)" autocomplete="off" />
<input id="druid-tag-label" data-schema="mailboxes.availableTags" class="input input-sm w-full" placeholder="label (e.g. BDC créé)" autocomplete="off" />
<button class="btn btn-primary btn-sm" data-action="druid-add-tag">+</button>
</div>
</div>
@@ -375,25 +418,25 @@
<div class="grid grid-cols-2 gap-2">
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="plugin.id">Plugin ID</span></label>
<input id="plugin-id" type="text" class="input input-sm w-full" placeholder="bdc-emailaction" autocomplete="off" spellcheck="false" />
<input id="plugin-id" data-schema="plugins.pluginId" type="text" class="input input-sm w-full" placeholder="bdc-emailaction" autocomplete="off" spellcheck="false" />
</fieldset>
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="plugin.placement">Placement</span></label>
<input id="plugin-placement" type="text" class="input input-sm w-full" placeholder="emailaction" autocomplete="off" spellcheck="false" />
<input id="plugin-placement" data-schema="plugins.placement" type="text" class="input input-sm w-full" placeholder="emailaction" autocomplete="off" spellcheck="false" />
</fieldset>
</div>
<div class="grid grid-cols-[1fr_auto_auto] gap-2 items-end">
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="plugin.name">Name</span></label>
<input id="plugin-name" type="text" class="input input-sm w-full" autocomplete="off" />
<input id="plugin-name" data-schema="plugins.name" type="text" class="input input-sm w-full" autocomplete="off" />
</fieldset>
<label class="label cursor-pointer justify-start gap-1 py-1">
<input id="plugin-enabled" type="checkbox" class="toggle toggle-primary toggle-sm" checked />
<input id="plugin-enabled" data-schema="plugins.enabled" type="checkbox" class="toggle toggle-primary toggle-sm" checked />
<span class="text-[11px] opacity-70" data-i18n="plugin.enabled">Enabled</span>
</label>
<fieldset class="fieldset w-16">
<label class="label py-1"><span class="text-xs" data-i18n="plugin.sort">Sort</span></label>
<input id="plugin-sort" type="number" min="0" value="10" class="input input-sm w-full" />
<input id="plugin-sort" data-schema="plugins.sort" type="number" min="0" value="10" class="input input-sm w-full" />
</fieldset>
</div>
@@ -401,28 +444,28 @@
<div class="grid grid-cols-2 gap-2">
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="plugin.tribe">Tribe</span></label>
<input id="plugin-target-tribe" type="text" class="input input-sm w-full" placeholder="(host tribe)" autocomplete="off" spellcheck="false" />
<input id="plugin-target-tribe" data-schema="plugins.target" type="text" class="input input-sm w-full" placeholder="(host tribe)" autocomplete="off" spellcheck="false" />
</fieldset>
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="plugin.webapp">Webapp</span></label>
<input id="plugin-target-webapp" type="text" class="input input-sm w-full" placeholder="gestion" autocomplete="off" spellcheck="false" />
<input id="plugin-target-webapp" data-schema="plugins.target" type="text" class="input input-sm w-full" placeholder="gestion" autocomplete="off" spellcheck="false" />
</fieldset>
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="plugin.subapp">Subapp</span></label>
<input id="plugin-target-subapp" type="text" class="input input-sm w-full" placeholder="bdc" autocomplete="off" spellcheck="false" />
<input id="plugin-target-subapp" data-schema="plugins.target" type="text" class="input input-sm w-full" placeholder="bdc" autocomplete="off" spellcheck="false" />
</fieldset>
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="plugin.screen">Screen</span></label>
<input id="plugin-target-screen" type="text" class="input input-sm w-full" placeholder="draft" autocomplete="off" spellcheck="false" />
<input id="plugin-target-screen" data-schema="plugins.target" type="text" class="input input-sm w-full" placeholder="list" autocomplete="off" spellcheck="false" />
</fieldset>
</div>
<div class="divider my-1 text-[10px] opacity-50" data-i18n="plugin.fields">Mini-form fields</div>
<div id="plugin-fields-editor" class="space-y-1"></div>
<div id="plugin-fields-editor" data-schema="plugins.fields" class="space-y-1"></div>
<button class="btn btn-ghost btn-xs btn-outline w-full" data-action="plugin-add-field">+ <span data-i18n="plugin.add_field">Add field</span></button>
<div class="divider my-1 text-[10px] opacity-50" data-i18n="plugin.buttons">Action buttons</div>
<div id="plugin-buttons-editor" class="space-y-1"></div>
<div id="plugin-buttons-editor" data-schema="plugins.buttons" class="space-y-1"></div>
<button class="btn btn-ghost btn-xs btn-outline w-full" data-action="plugin-add-button">+ <span data-i18n="plugin.add_button">Add button</span></button>
<div class="divider my-1 text-[10px] opacity-50" data-i18n="plugin.json">JSON preview</div>
@@ -436,11 +479,119 @@
</div>
</div>
<!-- Screen 6: Compose / reply / forward (outbox) -->
<div id="screen-compose" class="screen hidden">
<div class="flex items-center gap-2 mb-3">
<button class="btn btn-ghost btn-sm btn-square" data-action="compose-cancel" title="‹">‹</button>
<h2 id="compose-title" class="text-lg font-bold flex-1" data-i18n="compose.title">New message</h2>
</div>
<div id="compose-card" class="card bg-base-100 shadow-xl max-w-3xl w-full">
<div class="card-body p-4">
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="compose.from">From</span></label>
<input id="compose-from" type="email" class="input input-sm w-full min-w-0" readonly tabindex="-1" />
</fieldset>
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="compose.to">To</span></label>
<input id="compose-to" data-schema="outbox.to" type="text" class="input input-sm w-full min-w-0" placeholder="a@x.fr, b@y.fr" autocomplete="off" spellcheck="false" />
</fieldset>
<fieldset id="compose-fs-cc" class="fieldset hidden">
<label class="label py-1"><span class="text-xs" data-i18n="compose.cc">Cc</span></label>
<input id="compose-cc" data-schema="outbox.cc" type="text" class="input input-sm w-full min-w-0" placeholder="a@x.fr, b@y.fr" autocomplete="off" spellcheck="false" />
</fieldset>
<fieldset id="compose-fs-bcc" class="fieldset hidden">
<label class="label py-1"><span class="text-xs" data-i18n="compose.bcc">Bcc</span></label>
<input id="compose-bcc" data-schema="outbox.bcc" type="text" class="input input-sm w-full min-w-0" placeholder="a@x.fr, b@y.fr" autocomplete="off" spellcheck="false" />
</fieldset>
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="compose.subject">Subject</span></label>
<input id="compose-subject" data-schema="outbox.subject" type="text" class="input input-sm w-full min-w-0" autocomplete="off" />
</fieldset>
<fieldset class="fieldset">
<label class="label py-1"><span class="text-xs" data-i18n="compose.body">Message</span></label>
<!-- Host of the lazy SunEditor. The instance replaces #compose-body in place and
its carrier wrapper (dropdown trays, modals) is relocated here, so the whole
editor — toolbar included — stays inside the compose card. -->
<div id="compose-body-host" class="min-w-0 break-words">
<textarea id="compose-body" data-schema="outbox.html" class="textarea w-full min-w-0 text-sm break-words" rows="14" spellcheck="false"></textarea>
</div>
<p id="compose-editor-status" class="text-[11px] opacity-60 mt-1 min-w-0 break-words"></p>
</fieldset>
<div class="flex gap-2">
<button class="btn btn-ghost btn-sm flex-1" data-action="compose-cancel" data-i18n="compose.cancel">Cancel</button>
<button id="compose-send" class="btn btn-primary btn-sm flex-1" data-action="compose-send" data-i18n="compose.send">Send</button>
</div>
<!-- Template explorer (CODE storage, read-only, local render only).
Under the send form. The parent card (#compose-card) starts at
max-w-3xl and tplEnter() widens it to max-w-6xl. Grid stacks
vertically on mobile (grid-cols-1 lg:grid-cols-[300px_1fr]). -->
<div id="tpl-explorer" class="mt-4 border-t border-base-300 pt-3 min-w-0">
<div class="flex items-center justify-between gap-2 mb-2 min-w-0">
<h3 class="font-bold text-sm min-w-0 break-words" data-i18n="tpl.title">Templates</h3>
<span class="text-[10px] opacity-50 shrink-0 min-w-0 break-words" data-i18n="tpl.preview_local">Local preview only</span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-3 min-w-0">
<!-- Left: template list + test JSON -->
<div class="card bg-base-200/50 min-w-0">
<div class="card-body p-3 space-y-2 min-w-0">
<label class="label py-0 min-w-0" for="tpl-search"><span class="text-xs min-w-0 break-words" data-i18n="tpl.search">Search templates</span></label>
<input id="tpl-search" type="search" data-i18n-title="tpl.search" title="Search templates" class="input input-sm w-full min-w-0" placeholder="Search templates" autocomplete="off" spellcheck="false" />
<p class="text-xs font-medium min-w-0 break-words" data-i18n="tpl.list">Templates</p>
<ul id="tpl-list" class="space-y-1 max-h-[300px] overflow-auto min-w-0"></ul>
<label class="label py-0 min-w-0" for="tpl-test-json"><span class="text-xs min-w-0 break-words" data-i18n="tpl.test_data">Test data (JSON)</span></label>
<textarea id="tpl-test-json" class="textarea textarea-bordered textarea-xs w-full font-mono min-w-0 break-words" rows="6" spellcheck="false"></textarea>
<div class="flex flex-wrap gap-1 min-w-0">
<button id="tpl-test-btn" class="btn btn-primary btn-xs" data-action="tpl-test" data-i18n="tpl.test_btn">Test</button>
<button id="tpl-reset-btn" class="btn btn-ghost btn-xs" data-action="tpl-reset" data-i18n="tpl.reset_btn">Reset</button>
<button id="tpl-insert-btn" class="btn btn-secondary btn-xs" data-action="tpl-insert" data-i18n="tpl.insert_btn">Insert into message</button>
</div>
<p class="text-[10px] opacity-50 min-w-0 break-words" data-i18n="tpl.hint_test">Edit the JSON then click Test for a local render. No server call.</p>
</div>
</div>
<!-- Right: raw template + local preview -->
<div class="card bg-base-200/50 min-w-0">
<div class="card-body p-3 space-y-2 min-w-0">
<p class="text-xs font-medium min-w-0 break-words" data-i18n="tpl.raw_email">Raw template (email)</p>
<label class="label py-0 min-w-0"><span class="text-xs min-w-0 break-words" data-i18n="tpl.raw_subject">Subject (raw)</span></label>
<pre id="tpl-raw-subject" class="text-xs whitespace-pre-wrap break-words bg-base-100 rounded p-2 max-w-full overflow-auto min-w-0"></pre>
<label class="label py-0 min-w-0"><span class="text-xs min-w-0 break-words" data-i18n="tpl.raw_html">HTML (raw)</span></label>
<pre id="tpl-raw-html" class="text-[11px] whitespace-pre-wrap break-words bg-base-100 rounded p-2 max-h-[240px] overflow-auto max-w-full min-w-0"></pre>
<details class="min-w-0">
<summary class="text-xs cursor-pointer min-w-0 break-words" data-i18n="tpl.raw_text">Text (raw)</summary>
<pre id="tpl-raw-text" class="text-[11px] whitespace-pre-wrap break-words bg-base-100 rounded p-2 max-h-[160px] overflow-auto max-w-full min-w-0 mt-1"></pre>
</details>
<p class="text-xs font-medium min-w-0 break-words" data-i18n="tpl.extra">Extra properties</p>
<div id="tpl-extra-props" class="min-w-0 break-words overflow-auto text-xs"></div>
<div class="divider my-1"></div>
<p class="text-xs font-medium min-w-0 break-words" data-i18n="tpl.preview">Preview (local render)</p>
<p class="text-xs opacity-60 min-w-0 break-words" data-i18n="tpl.preview_subject">Subject (preview)</p>
<div id="tpl-preview-subject" class="text-sm font-medium min-w-0 break-words"></div>
<div id="tpl-preview-frame-wrap" class="min-w-0">
<iframe id="tpl-preview-frame" class="w-full border-0 rounded bg-base-100 min-w-0" style="min-height:300px" sandbox="allow-popups" title="template preview"></iframe>
</div>
<pre id="tpl-preview-text" class="text-[11px] whitespace-pre-wrap break-words bg-base-100 rounded p-2 max-h-[160px] overflow-auto max-w-full min-w-0"></pre>
<p class="text-[10px] opacity-50 min-w-0 break-words" data-i18n="tpl.hint_select">Select a template to see its raw content and preview.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/cdn/dev/lib/apxtri/guard.js"></script>
<script src="/cdn/dev/lib/apxtri/notify.js"></script>
<div id="inspect-debug" class="hidden fixed bottom-2 right-2 z-50 bg-neutral text-neutral-content text-xs px-2 py-1 rounded font-mono opacity-70">
<div id="inspect-screen">screen: home</div>
<div id="inspect-action" class="hidden"></div>
</div>
<script src="js/app.js"></script>
</body>
</html>
</html>
+1599 -95
View File
File diff suppressed because it is too large Load Diff
+60 -2
View File
@@ -167,5 +167,63 @@
"mail.no_more_tags": "All tags applied",
"mail.no_tags_available": "No tags available",
"mail.tag_added": "Tag added",
"mail.tag_removed": "Tag removed"
}
"mail.tag_removed": "Tag removed",
"mail.add_tag": "Add a tag",
"mail.remove_tag": "Remove tag",
"mail.compose": "Compose",
"mail.reply": "Reply",
"mail.reply_all": "Reply all",
"mail.forward": "Forward",
"mail.toggle_read": "Toggle read/unread",
"compose.title": "New message",
"compose.title_reply": "Reply",
"compose.title_reply_all": "Reply all",
"compose.title_forward": "Forward",
"compose.send": "Send",
"compose.cancel": "Cancel",
"compose.from": "From",
"compose.to": "To",
"compose.cc": "Cc",
"compose.bcc": "Bcc",
"compose.subject": "Subject",
"compose.date": "Date",
"compose.body": "Message",
"compose.editor.loading": "Loading editor…",
"compose.editor.fallback": "Rich text editor unavailable — plain text mode.",
"compose.no_smtp": "This mailbox has no SMTP configuration",
"compose.sent": "Message sent",
"compose.err.to_required": "At least one recipient is required",
"compose.err.to_invalid": "Invalid recipient address: {{value}}",
"compose.err.cc_invalid": "Invalid Cc address: {{value}}",
"compose.err.bcc_invalid": "Invalid Bcc address: {{value}}",
"compose.err.body_required": "The message cannot be empty",
"compose.err.missing_fields": "Missing required field: {{field}}",
"compose.err.invalid_headers": "Invalid threading header {{field}}: {{value}}",
"compose.err.send_failed": "Send failed: {{reason}}",
"compose.err.failed": "Send failed: {{reason}}",
"compose.err.right_required": "Write access on this mailbox is required to send",
"compose.err.mailbox_missing": "This mailbox no longer exists",
"compose.err.no_mailbox": "No mailbox open — open a mailbox before writing",
"conn.smtp_password": "SMTP password",
"tpl.title": "Message templates",
"tpl.search": "Search templates…",
"tpl.list": "Templates",
"tpl.empty": "No templates available",
"tpl.test_data": "Test data (JSON)",
"tpl.test_btn": "Test",
"tpl.reset_btn": "Reset",
"tpl.insert_btn": "Insert into message",
"tpl.raw_email": "Raw template (email)",
"tpl.raw_subject": "Subject (raw)",
"tpl.raw_html": "HTML (raw)",
"tpl.raw_text": "Text (raw)",
"tpl.extra": "Extra properties",
"tpl.preview": "Preview (local render)",
"tpl.preview_subject": "Subject (preview)",
"tpl.hint_select": "Select a template to see its raw content and preview.",
"tpl.hint_test": "Edit the JSON then click Test for a local render. No server call.",
"tpl.err.invalid_json": "Invalid test JSON",
"tpl.err.load_failed": "Template load failed",
"tpl.empty_value": "(empty)",
"tpl.preview_local": "Local preview only"
}
+60 -2
View File
@@ -167,5 +167,63 @@
"mail.no_more_tags": "Tous les tags appliqués",
"mail.no_tags_available": "Aucun tag disponible",
"mail.tag_added": "Tag ajouté",
"mail.tag_removed": "Tag retiré"
}
"mail.tag_removed": "Tag retiré",
"mail.add_tag": "Ajouter un tag",
"mail.remove_tag": "Retirer le tag",
"mail.compose": "Composer",
"mail.reply": "Répondre",
"mail.reply_all": "Répondre à tous",
"mail.forward": "Transférer",
"mail.toggle_read": "Basculer lu/non-lu",
"compose.title": "Nouveau message",
"compose.title_reply": "Répondre",
"compose.title_reply_all": "Répondre à tous",
"compose.title_forward": "Transférer",
"compose.send": "Envoyer",
"compose.cancel": "Annuler",
"compose.from": "De",
"compose.to": "À",
"compose.cc": "Cc",
"compose.bcc": "Cci",
"compose.subject": "Objet",
"compose.date": "Date",
"compose.body": "Message",
"compose.editor.loading": "Chargement de l'éditeur…",
"compose.editor.fallback": "Éditeur de texte enrichi indisponible — mode texte brut.",
"compose.no_smtp": "Cette boîte n'a aucune configuration SMTP",
"compose.sent": "Message envoyé",
"compose.err.to_required": "Au moins un destinataire est requis",
"compose.err.to_invalid": "Adresse de destinataire invalide : {{value}}",
"compose.err.cc_invalid": "Adresse Cc invalide : {{value}}",
"compose.err.bcc_invalid": "Adresse Cci invalide : {{value}}",
"compose.err.body_required": "Le message ne peut pas être vide",
"compose.err.missing_fields": "Champ requis manquant : {{field}}",
"compose.err.invalid_headers": "En-tête de fil invalide {{field}} : {{value}}",
"compose.err.send_failed": "Échec de l'envoi : {{reason}}",
"compose.err.failed": "Échec de l'envoi : {{reason}}",
"compose.err.right_required": "Le droit d'écriture sur cette boîte est requis pour envoyer",
"compose.err.mailbox_missing": "Cette boîte n'existe plus",
"compose.err.no_mailbox": "Aucune boîte ouverte — ouvrez une boîte avant d'écrire",
"conn.smtp_password": "SMTP password",
"tpl.title": "Modèles de message",
"tpl.search": "Rechercher un modèle…",
"tpl.list": "Modèles",
"tpl.empty": "Aucun modèle disponible",
"tpl.test_data": "Données de test (JSON)",
"tpl.test_btn": "Tester",
"tpl.reset_btn": "Réinitialiser",
"tpl.insert_btn": "Insérer dans le message",
"tpl.raw_email": "Modèle brut (email)",
"tpl.raw_subject": "Objet (brut)",
"tpl.raw_html": "HTML (brut)",
"tpl.raw_text": "Texte (brut)",
"tpl.extra": "Propriétés supplémentaires",
"tpl.preview": "Aperçu (rendu local)",
"tpl.preview_subject": "Objet (aperçu)",
"tpl.hint_select": "Sélectionnez un modèle pour voir son contenu brut et son aperçu.",
"tpl.hint_test": "Modifiez le JSON puis cliquez sur Tester pour un rendu local. Aucun appel serveur.",
"tpl.err.invalid_json": "JSON de test invalide",
"tpl.err.load_failed": "Échec du chargement du modèle",
"tpl.empty_value": "(vide)",
"tpl.preview_local": "Aperçu local uniquement"
}
-1
View File
@@ -16,7 +16,6 @@ self.addEventListener('install', event => {
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
if (event.request.url.includes('/draft/')) return event.respondWith(fetch(event.request));
event.respondWith((async () => {
try {
const networkResponse = await fetch(event.request);
+4 -4
View File
@@ -15,7 +15,7 @@
<script src="/cdn/dev/lib/axios/dist/axios.min.js"></script>
<script src="/cdn/dev/lib/openpgp/dist/openpgp.min.js"></script>
<script src="/cdn/dev/lib/dayjs/dayjs.min.js"></script>
<script src="/cdn/dev/lib/apxtri/draft.js"></script>
<script src="/cdn/dev/lib/apxtri/inspect.js"></script>
<script src="/cdn/dev/lib/apxtri/auth.js"></script>
<script src="/cdn/dev/lib/apxtri/localdb.js"></script>
<link href="/cdn/dev/css/base.css" rel="stylesheet" /><link href="/cdn/dev/css/mynation.css" rel="stylesheet" />
@@ -85,9 +85,9 @@
</div>
</div>
<div id="draft-debug" class="hidden fixed bottom-2 right-2 z-50 bg-neutral text-neutral-content text-xs px-2 py-1 rounded font-mono opacity-70">
<div id="draft-screen">screen: home</div>
<div id="draft-action" class="hidden"></div>
<div id="inspect-debug" class="hidden fixed bottom-2 right-2 z-50 bg-neutral text-neutral-content text-xs px-2 py-1 rounded font-mono opacity-70">
<div id="inspect-screen">screen: home</div>
<div id="inspect-action" class="hidden"></div>
</div>
<script src="/cdn/dev/lib/apxtri/menu.js"></script>
+1 -25
View File
@@ -4,7 +4,7 @@
* Dock: browse nations, explore their towns and tribes.
* BUILD: formatted with prettier for readability; minified by buildpwa (terser) for production.
*
* DRAFT MODE: screen badge + hover data-action.
* INSPECT MODE: screen badge + hover data-action.
* ALL selectors use [data-action]; every clickable element has data-action attribute.
*/
const APP_TRIBE = document.querySelector('meta[name="xtribe"]')?.content || 'dev';
@@ -116,9 +116,6 @@ var App = {
Menu.showScreen = function (name) {
App.currentScreen = name;
origShowScreen(name);
// Update draft debug badge
var s = document.getElementById('draft-screen');
if (s) s.textContent = 'screen: ' + name;
};
Menu.showScreen('home');
@@ -373,27 +370,6 @@ var App = {
document.querySelector('[data-action="dir-refresh"]')?.addEventListener('click', function () { App.loadDirectory(); });
document.querySelector('[data-action="dir-resolve"]')?.addEventListener('click', function () { App.resolveAddress(); });
document.getElementById('dir-address')?.addEventListener('keydown', function (e) { if (e.key === 'Enter') App.resolveAddress(); });
// Draft mode hover debug
if (typeof IS_DRAFT !== 'undefined' && IS_DRAFT) {
document.getElementById('draft-debug')?.classList.remove('hidden');
document.body.addEventListener('mouseover', function (e) {
var t = e.target.closest('button,a,label,input,[data-action]');
if (t) {
var a = document.getElementById('draft-action');
if (a) {
var action = t.dataset ? t.dataset.action : null;
if (action) {
a.textContent = 'data-action:' + action;
a.classList.remove('hidden');
}
}
} else {
var d = document.getElementById('draft-action');
d && d.classList.add('hidden');
}
});
}
}
};
-3
View File
@@ -26,9 +26,6 @@ self.addEventListener('install', function (event) {
self.addEventListener('fetch', function (event) {
if (event.request.method !== 'GET') return;
if (event.request.url.indexOf('/draft/') !== -1) {
return event.respondWith(fetch(event.request));
}
event.respondWith(
(async function () {
try {
+1 -1
View File
@@ -133,7 +133,7 @@ Purpose: FAQ about Thread. No navigable actions (read-only).
| File | Description |
|------|-------------|
| `index.html` | Screens, menu container, draft-debug, modal, i18n bindings |
| `index.html` | Screens, menu container, inspect-debug, modal, i18n bindings |
| `manifest.json` | PWA manifest (identity, icons) |
| `css/style.css` | Tailwind/daisyui source; themes `identities-light`/`identities-dark` |
| `css/output.css` | Compiled Tailwind (generated) |
+11 -11
View File
@@ -17,7 +17,7 @@
<script src="/cdn/dev/lib/openpgp/dist/openpgp.min.js"></script>
<script src="/cdn/dev/lib/dayjs/dayjs.min.js"></script>
<script src="/cdn/dev/lib/dayjs/plugin/relativeTime.js"></script>
<script src="/cdn/dev/lib/apxtri/draft.js"></script>
<script src="/cdn/dev/lib/apxtri/inspect.js"></script>
<script src="/cdn/dev/lib/apxtri/auth.js"></script>
<script src="/cdn/dev/lib/apxtri/localdb.js"></script>
<link href="/cdn/dev/css/base.css" rel="stylesheet" /><link href="/cdn/dev/css/mythread.css" rel="stylesheet" />
@@ -110,7 +110,7 @@
<div id="thread-input-area" class="hidden px-4 py-3 border-t border-base-300">
<div id="attachment-preview" class="hidden mb-2"></div>
<div class="flex items-end gap-2">
<textarea id="thread-input" rows="1" maxlength="1500" class="textarea textarea-bordered flex-1 resize-none" data-i18n="placeholder.thread.message" placeholder="Type your message..." autocomplete="off"></textarea>
<textarea id="thread-input" data-schema="thread.message" rows="1" maxlength="1500" class="textarea textarea-bordered flex-1 resize-none" data-i18n="placeholder.thread.message" placeholder="Type your message..." autocomplete="off"></textarea>
<div class="flex flex-col items-end gap-1">
<div class="flex gap-1">
<button class="btn btn-ghost btn-sm btn-square text-accent" data-action="attach-image" title="Attach an image / screenshot">
@@ -153,7 +153,7 @@
<div id="new-public-fields" class="hidden space-y-3">
<div class="form-control">
<label class="label"><span class="label-text" data-i18n="new.type_label">Type</span></label>
<select id="new-type" class="select select-bordered w-full">
<select id="new-type" data-schema="thread_public.type" class="select select-bordered w-full">
<option value="annonce" data-i18n="public.type.annonce">Ad</option>
<option value="besoin" data-i18n="public.type.besoin">Need</option>
<option value="service" data-i18n="public.type.service">Service</option>
@@ -163,19 +163,19 @@
</div>
<div class="form-control">
<label class="label"><span class="label-text" data-i18n="new.contactprice_label">Contact price (APX3)</span></label>
<input id="new-contactprice" type="number" class="input input-bordered w-full" min="0" step="0.1" value="0" autocomplete="off" />
<input id="new-contactprice" data-schema="thread_public.contactPrice" type="number" class="input input-bordered w-full" min="0" step="0.1" value="0" autocomplete="off" />
<label class="label"><span class="label-text-alt text-xs opacity-50" data-i18n="new.contactprice_hint">Price visitors pay to contact you. 0 = free.</span></label>
</div>
<div class="form-control">
<label class="label"><span class="label-text" data-i18n="new.link_label">Link (optional)</span></label>
<input id="new-link" type="text" class="input input-bordered w-full" placeholder="https://..." autocomplete="off" />
<input id="new-link" data-schema="thread_public.link" type="text" class="input input-bordered w-full" placeholder="https://..." autocomplete="off" />
</div>
<div class="form-control mt-3">
<label class="label"><span class="label-text" data-i18n="new.location_label">Location (optional)</span></label>
<div id="new-location-selected" class="flex flex-wrap gap-1 mb-1"></div>
<div class="flex gap-1 items-start">
<select id="new-location-country" class="select select-bordered select-xs flex-1"><option value="">— Country —</option></select>
<select id="new-location-region" class="select select-bordered select-xs flex-1"><option value="">— Region —</option></select>
<select id="new-location-country" data-schema="thread_public.location" class="select select-bordered select-xs flex-1"><option value="">— Country —</option></select>
<select id="new-location-region" data-schema="thread_public.location" class="select select-bordered select-xs flex-1"><option value="">— Region —</option></select>
<button class="btn btn-primary btn-xs" data-action="set-location" data-i18n="profile.add">Add</button>
</div>
</div>
@@ -184,7 +184,7 @@
<div id="new-private-fields">
<div class="form-control">
<label class="label"><span class="label-text" data-i18n="new.participants_label">Participants (aliases, comma-separated)</span></label>
<input id="new-participants" type="text" class="input input-bordered w-full" placeholder="alice, bob" autocomplete="off" />
<input id="new-participants" data-schema="thread.aliases" type="text" class="input input-bordered w-full" placeholder="alice, bob" autocomplete="off" />
<label class="label"><span class="label-text-alt text-xs opacity-50" data-i18n="new.participants_hint">e.g. alice, bob, charlie</span></label>
</div>
</div>
@@ -261,9 +261,9 @@
</div>
</div>
<div id="draft-debug" class="hidden fixed bottom-2 right-2 z-50 bg-neutral text-neutral-content text-xs px-2 py-1 rounded font-mono opacity-70">
<div id="draft-screen">screen: list</div>
<div id="draft-action" class="hidden"></div>
<div id="inspect-debug" class="hidden fixed bottom-2 right-2 z-50 bg-neutral text-neutral-content text-xs px-2 py-1 rounded font-mono opacity-70">
<div id="inspect-screen">screen: list</div>
<div id="inspect-action" class="hidden"></div>
</div>
<div id="passphrase-modal" class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center hidden">
+1 -22
View File
@@ -883,7 +883,7 @@ const App = {
const atts = (Array.isArray(m.attachments) ? m.attachments : []).map(a => this._renderAttachment(m, a)).join('');
const actions = this._renderActions(m);
const delBtn = isMine ? `<button class="btn btn-ghost btn-xs text-error" data-action="delete-message" data-priv="${this._esc(m.privatriid)}" title="Delete message">✕</button>` : '';
return `<div class="chat ${isMine ? 'chat-end' : 'chat-start'}"><div class="chat-header text-xs opacity-50">${m.sender_alias || '...'}</div><div class="chat-bubble ${isMine ? 'chat-bubble-success' : 'chat-bubble-primary'} text-sm">${text}${atts}</div>${actions}<div class="chat-footer text-xs opacity-30 flex items-center gap-2">${ts}${delBtn}</div></div>`;
return `<div class="chat ${isMine ? 'chat-end' : 'chat-start'}"><div class="chat-header text-xs opacity-50" data-schema="thread.sender_alias">${m.sender_alias || '...'}</div><div class="chat-bubble ${isMine ? 'chat-bubble-success' : 'chat-bubble-primary'} text-sm" data-schema="thread.message">${text}${atts}</div>${actions}<div class="chat-footer text-xs opacity-30 flex items-center gap-2">${ts}${delBtn}</div></div>`;
}).join('');
container.classList.remove('hidden');
setTimeout(() => { container.scrollTop = container.scrollHeight; }, 50);
@@ -1782,27 +1782,6 @@ const App = {
document.getElementById('new-location-selected').innerHTML = '';
}
});
// Draft mode hover debug badge (mirrors mynation/mytown/mydev pattern)
if (typeof IS_DRAFT !== 'undefined' && IS_DRAFT) {
document.getElementById('draft-debug')?.classList.remove('hidden');
document.body.addEventListener('mouseover', function (e) {
var t = e.target.closest('button,a,label,input,[data-action]');
if (t) {
var a = document.getElementById('draft-action');
if (a) {
var action = t.dataset ? t.dataset.action : null;
if (action) {
a.textContent = 'data-action:' + action;
a.classList.remove('hidden');
}
}
} else {
var d = document.getElementById('draft-action');
d && d.classList.add('hidden');
}
});
}
},
async _loadLocationOptions() {
if (this._locationOptionsLoaded) return;
-3
View File
@@ -20,9 +20,6 @@ self.addEventListener('install', event => {
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
if (event.request.url.includes('/draft/')) {
return event.respondWith(fetch(event.request));
}
event.respondWith(
fetch(event.request)
.then(response => {
+4 -4
View File
@@ -15,7 +15,7 @@
<script src="/cdn/dev/lib/axios/dist/axios.min.js"></script>
<script src="/cdn/dev/lib/openpgp/dist/openpgp.min.js"></script>
<script src="/cdn/dev/lib/dayjs/dayjs.min.js"></script>
<script src="/cdn/dev/lib/apxtri/draft.js"></script>
<script src="/cdn/dev/lib/apxtri/inspect.js"></script>
<script src="/cdn/dev/lib/apxtri/auth.js"></script>
<script src="/cdn/dev/lib/apxtri/localdb.js"></script>
<link href="/cdn/dev/css/base.css" rel="stylesheet" /><link href="/cdn/dev/css/mytown.css" rel="stylesheet" />
@@ -51,9 +51,9 @@
</div>
</div>
<div id="draft-debug" class="hidden fixed bottom-2 right-2 z-50 bg-neutral text-neutral-content text-xs px-2 py-1 rounded font-mono opacity-70">
<div id="draft-screen">screen: home</div>
<div id="draft-action" class="hidden"></div>
<div id="inspect-debug" class="hidden fixed bottom-2 right-2 z-50 bg-neutral text-neutral-content text-xs px-2 py-1 rounded font-mono opacity-70">
<div id="inspect-screen">screen: home</div>
<div id="inspect-action" class="hidden"></div>
</div>
<script src="/cdn/dev/lib/apxtri/menu.js"></script>
+1 -25
View File
@@ -4,7 +4,7 @@
* Dock: displays town information and management options.
* BUILD: formatted with prettier for readability; minified by buildpwa (terser) for production.
*
* DRAFT MODE: screen badge + hover data-action.
* INSPECT MODE: screen badge + hover data-action.
* ALL selectors use [data-action]; every clickable element has data-action attribute.
*/
const APP_TRIBE = document.querySelector('meta[name="xtribe"]')?.content || 'dev';
@@ -97,9 +97,6 @@ var App = {
Menu.showScreen = function (name) {
App.currentScreen = name;
origShowScreen(name);
// Update draft debug badge
var s = document.getElementById('draft-screen');
if (s) s.textContent = 'screen: ' + name;
};
Menu.showScreen('home');
@@ -127,27 +124,6 @@ var App = {
// === EVENT BINDING ===
bindEvents: function () {
Log.debug('bindEvents()');
// Draft mode hover debug
if (typeof IS_DRAFT !== 'undefined' && IS_DRAFT) {
document.getElementById('draft-debug')?.classList.remove('hidden');
document.body.addEventListener('mouseover', function (e) {
var t = e.target.closest('button,a,label,input,[data-action]');
if (t) {
var a = document.getElementById('draft-action');
if (a) {
var action = t.dataset ? t.dataset.action : null;
if (action) {
a.textContent = 'data-action:' + action;
a.classList.remove('hidden');
}
}
} else {
var d = document.getElementById('draft-action');
d && d.classList.add('hidden');
}
});
}
}
};
-3
View File
@@ -26,9 +26,6 @@ self.addEventListener('install', function (event) {
self.addEventListener('fetch', function (event) {
if (event.request.method !== 'GET') return;
if (event.request.url.indexOf('/draft/') !== -1) {
return event.respondWith(fetch(event.request));
}
event.respondWith(
(async function () {
try {
+21 -10
View File
@@ -20,7 +20,7 @@
<script src="/cdn/dev/lib/codemirror/codemirror.js"></script>
<script src="/cdn/dev/lib/codemirror/javascript/javascript.js"></script>
<script src="/cdn/dev/lib/codemirror/css/css.js"></script>
<script src="/cdn/dev/lib/apxtri/draft.js"></script>
<script src="/cdn/dev/lib/apxtri/inspect.js"></script>
<script src="/cdn/dev/lib/apxtri/auth.js"></script>
<script src="/cdn/dev/lib/apxtri/localdb.js"></script>
<script src="/cdn/dev/lib/bip39/bip39.js"></script>
@@ -60,17 +60,17 @@
<h2 class="card-title" data-i18n="create.title">Create a new tribe</h2>
<div class="form-control">
<label class="label"><span class="label-text" data-i18n="create.tribe_name_label">Tribe name</span></label>
<input id="create-name" type="text" class="input input-bordered w-full" pattern="[a-z0-9]*" minlength="3" maxlength="30" placeholder="mytribe" autocomplete="off" />
<input id="create-name" data-schema="tribes.tribeId" type="text" class="input input-bordered w-full" pattern="[a-z0-9]*" minlength="3" maxlength="30" placeholder="mytribe" autocomplete="off" />
<label class="label"><span class="label-text-alt text-xs opacity-50" id="create-name-hint" data-i18n="create.tribe_name_hint">lowercase (a-z) and digits (0-9), 3-30 chars</span></label>
</div>
<div class="form-control mt-2">
<label class="label"><span class="label-text" data-i18n="create.short_label">Short description</span></label>
<input id="create-short" type="text" class="input input-bordered w-full" maxlength="200" placeholder="Short tagline" autocomplete="off" />
<input id="create-short" data-schema="tribes.short" type="text" class="input input-bordered w-full" maxlength="200" placeholder="Short tagline" autocomplete="off" />
<label class="label"><span class="label-text-alt text-xs opacity-50" data-i18n="create.short_hint">Max 200 characters</span></label>
</div>
<div class="form-control mt-2">
<label class="label"><span class="label-text" data-i18n="create.description_label">Description</span></label>
<textarea id="create-description" class="textarea textarea-bordered w-full" rows="3" placeholder="What is this tribe about?" autocomplete="off"></textarea>
<textarea id="create-description" data-schema="tribes.description" class="textarea textarea-bordered w-full" rows="3" placeholder="What is this tribe about?" autocomplete="off"></textarea>
</div>
<div class="form-control mt-2">
<label class="label cursor-pointer justify-start gap-3">
@@ -140,6 +140,10 @@
<div class="card-body p-4">
<h3 class="font-bold text-sm mb-2" data-i18n="objects.schemas_title">Schemas</h3>
<div id="objects-schemas-list" class="flex flex-wrap gap-2"></div>
<div id="objects-schema-create" class="hidden mt-2 flex gap-2">
<input id="objects-schema-new-name" type="text" class="input input-bordered input-xs flex-1 min-w-0" data-i18n="objects.schema_create_placeholder" placeholder="New object name" autocomplete="off" />
<button class="btn btn-primary btn-xs shrink-0" data-action="schema-create" data-i18n="objects.schema_create">Create schema</button>
</div>
</div>
</div>
@@ -150,7 +154,7 @@
<div id="objects-backup-list" class="flex flex-wrap gap-2 mb-2"></div>
<button class="btn btn-xs btn-ghost w-full" id="btn-backup-add">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.5v15m7.5-7.5h-15"/></svg>
<span data-i18n="objects.backup_add">Add schema</span>
<span data-i18n="objects.backup_add">Add object to backup</span>
</button>
</div>
</div>
@@ -358,8 +362,10 @@
<div class="modal-box bg-base-100">
<h3 class="font-bold text-lg" data-i18n="tribeinfo.unsaved_title">Unsaved changes</h3>
<p class="py-4 text-sm opacity-70" data-i18n="tribeinfo.unsaved_desc">You have unsaved changes. Are you sure you want to leave?</p>
<p id="unsaved-schema-hint" class="pb-4 text-sm text-warning hidden" data-i18n="objects.unsaved_schema_hint">The schema has unsaved edits. Save it before closing?</p>
<div class="modal-action">
<button class="btn btn-ghost" data-action="unsaved-stay" data-i18n="tribeinfo.unsaved_stay">Stay</button>
<button class="btn btn-primary hidden" data-action="unsaved-save" data-i18n="tribeinfo.unsaved_save">Save</button>
<button class="btn btn-primary" data-action="unsaved-leave" data-i18n="tribeinfo.unsaved_leave">Leave</button>
</div>
</div>
@@ -427,25 +433,25 @@
<div class="space-y-3">
<div class="form-control">
<label class="label"><span class="label-text" data-i18n="actions.tag_label">Tag (id)</span></label>
<input id="action-tag" type="text" class="input input-bordered input-sm w-full" placeholder="myaction" autocomplete="off" />
<input id="action-tag" data-schema="actions.tag" type="text" class="input input-bordered input-sm w-full" placeholder="myaction" autocomplete="off" />
</div>
<div class="form-control">
<label class="label"><span class="label-text" data-i18n="actions.title_label">Title</span></label>
<input id="action-title" type="text" class="input input-bordered input-sm w-full" placeholder="My action" autocomplete="off" />
<input id="action-title" data-schema="actions.title" type="text" class="input input-bordered input-sm w-full" placeholder="My action" autocomplete="off" />
</div>
<div class="grid grid-cols-2 gap-2">
<div class="form-control">
<label class="label"><span class="label-text" data-i18n="actions.accept_label">Accept label</span></label>
<input id="action-accept-label" type="text" class="input input-bordered input-sm w-full" placeholder="Accept" autocomplete="off" />
<input id="action-accept-label" data-schema="actions.acceptLabel" type="text" class="input input-bordered input-sm w-full" placeholder="Accept" autocomplete="off" />
</div>
<div class="form-control">
<label class="label"><span class="label-text" data-i18n="actions.refuse_label">Refuse label</span></label>
<input id="action-refuse-label" type="text" class="input input-bordered input-sm w-full" placeholder="Refuse" autocomplete="off" />
<input id="action-refuse-label" data-schema="actions.refuseLabel" type="text" class="input input-bordered input-sm w-full" placeholder="Refuse" autocomplete="off" />
</div>
</div>
<div class="form-control">
<label class="label"><span class="label-text" data-i18n="actions.prompt_label">Prompt (markdown)</span></label>
<textarea id="action-prompt" rows="8" class="textarea textarea-bordered w-full text-sm" placeholder="Describe precisely the behavior to code..."></textarea>
<textarea id="action-prompt" data-schema="actions.prompt" rows="8" class="textarea textarea-bordered w-full text-sm" placeholder="Describe precisely the behavior to code..."></textarea>
</div>
</div>
<div class="modal-action">
@@ -537,6 +543,11 @@
<script src="/cdn/dev/lib/apxtri/menu.js"></script>
<script src="/cdn/dev/lib/apxtri/notify.js"></script>
<div id="inspect-debug" class="hidden fixed bottom-2 right-2 z-50 bg-neutral text-neutral-content text-xs px-2 py-1 rounded font-mono opacity-70">
<div id="inspect-screen">screen: list</div>
<div id="inspect-action" class="hidden"></div>
</div>
<script src="js/app.js"></script>
</body>
</html>
+1908 -100
View File
File diff suppressed because it is too large Load Diff
+50 -1
View File
@@ -24,6 +24,9 @@
"members.profile_remove": "Entfernen",
"members.profile_saved": "Profil aktualisiert",
"members.profile_error": "Fehler beim Aktualisieren des Profils",
"members.profil_open": "In Mitglieder öffnen",
"members.profil_bar_title": "Stammesdateien",
"members.profil_unavailable": "Mitglieder-App nicht verfügbar",
"drawer.mail": "Post",
"members.filter_placeholder": "Mitglieder filtern...",
"members.avatar_upload": "Avatar hochladen",
@@ -44,5 +47,51 @@
"label.apxgain": "Monatliche APX3-Gewinne",
"label.druidsinfo": "Druiden-Notizen",
"label.grants": "Zugriffsrechte",
"webapps.open_app_com": "Die com-Unteranwendung öffnen"
"webapps.open_app_com": "Die com-Unteranwendung öffnen",
"objects.backup_add": "Objekt zum Backup hinzufügen",
"objects.schema_create": "Schema erstellen",
"objects.schema_create_placeholder": "Neuer Objektname (A-Z, 0-9, _, -)",
"objects.schema_create_invalid": "Ungültiger Name — nur Buchstaben, Ziffern, _ oder - (z. B. test42)",
"objects.schema_create_exists": "Schema {{obj}} existiert bereits",
"objects.schema_create_success": "Schema {{obj}} erstellt — Speicher objects/{{obj}}/{itm,idx,conf.json} angelegt",
"objects.schema_audit_title": "Schema-Prüfung vor dem Speichern",
"objects.schema_audit_summary": "{{bad}} Einträge mit Anomalien von {{audited}} geprüften ({{total}} insgesamt)",
"objects.schema_audit_copy": "Bericht kopieren",
"objects.schema_audit_copied": "Bericht kopiert",
"objects.schema_audit_confirm": "Neues Schema bestätigen",
"objects.schema_audit_cancel": "Abbrechen",
"objects.schema_audit_removed": "{{prop}} (Zeile {{line}}): Feld nicht im neuen Schema",
"objects.items_count": "{{n}} Einträge",
"objects.items_more": "…und {{n}} weitere",
"objects.item_replace_confirm": "{{apxid}} existiert bereits, ersetzen?",
"tribeinfo.unsaved_save": "Speichern",
"objects.unsaved_schema_hint": "Das Schema hat ungespeicherte Änderungen. Vor dem Schließen speichern?",
"objects.search_invalid_json": "Ungültige JSON-Abfrage",
"objects.search_hint_shapes": "Unterstützte Formen: {\"txt\":\"...\",\"algo\":\"match\",\"fieldstring\":\"prop\"}, {\"indexfilter\":{\"key\":\"lst_id\"}}, {\"apxid\":[\"id1\"]}, {\"fields\":\"all\"}",
"objects.search_hint_condition": "{condition:[...]} wird nicht unterstützt — verwende {\"txt\":\"...\",\"algo\":\"match\",\"fieldstring\":\"prop\"}",
"objects.item_invalid_apxid": "{{prop}} (Zeile {{line}}): Kennung fehlt oder ist leer",
"objects.item_missing_required": "{{prop}} (Zeile {{line}}): Pflichtfeld fehlt",
"objects.item_wrong_type": "{{prop}} (Zeile {{line}}): {{expected}} erwartet",
"objects.item_created": "Eintrag erstellt",
"objects.item_saved": "Eintrag gespeichert",
"objects.item_create_failed": "Erstellen des Eintrags fehlgeschlagen",
"objects.item_save_failed": "Speichern des Eintrags fehlgeschlagen",
"objects.item_delete": "Löschen",
"objects.item_delete_confirm": "{{apxid}} endgültig löschen?",
"objects.item_deleted": "Eintrag gelöscht",
"objects.item_delete_failed": "Löschen des Eintrags fehlgeschlagen",
"objects.usage": "Verwendung",
"objects.usage_title": "Verwendung von {{apxid}}",
"objects.usage_line": "{{obj}} · {{prop}} → {{n}} Einträge: {{ids}}",
"objects.usage_none": "Keine Verweise gefunden",
"app.yes": "Ja",
"app.no": "Nein",
"objects.reindex": "Neu indexieren",
"objects.reindex_done": "Index neu erstellt",
"objects.reindex_single_done": "Index {{index}} neu erstellt",
"objects.reindex_skipped": "Nichts neu zu erstellen",
"objects.reindex_failed": "Neuindexierung fehlgeschlagen",
"objects.item_rename_confirm": "{{old}} in {{new}} umbenennen?",
"objects.item_renamed": "Eintrag {{old}} in {{new}} umbenannt",
"objects.item_rename_conflict": "{{old}} kann nicht umbenannt werden: {{new}} existiert bereits"
}
+85 -4
View File
@@ -83,6 +83,9 @@
"members.profile_remove": "Remove",
"members.profile_saved": "Profile updated",
"members.profile_error": "Error updating profile",
"members.profil_open": "Open in Members",
"members.profil_bar_title": "Tribe files",
"members.profil_unavailable": "Members app unavailable",
"repo.title": "Repository",
"repo.hint": "Configure a Git repository for this tribe. The first \"Pull & deploy\" clones the repo into the tribe code directory; later pulls deploy new versions.",
"repo.url_label": "Repository URL",
@@ -100,6 +103,7 @@
"repo.url_required": "Repository URL is required",
"repo.error": "Error: {{msg}}",
"drawer.mail": "Mail",
"drawer.contacts": "Contacts",
"members.filter_placeholder": "Filter members...",
"members.avatar_upload": "Upload avatar",
"members.avatar_saved": "Avatar saved",
@@ -119,6 +123,13 @@
"label.apxgain": "Monthly APX3 gains",
"label.druidsinfo": "Druid notes",
"label.grants": "Access grants",
"label.nom": "Last name",
"label.prenom": "First name",
"label.genre": "Gender",
"label.titre": "Title",
"label.telephone": "Phone",
"label.mobile": "Mobile",
"label.fixe": "Landline",
"invite_req.success": "Invitation request sent to the tribe druid",
"invite_req.error": "Error sending invitation request",
"list.edit_btn": "Edit",
@@ -152,6 +163,7 @@
"tribeinfo.unsaved_desc": "You have unsaved changes. Are you sure you want to leave?",
"tribeinfo.unsaved_stay": "Stay",
"tribeinfo.unsaved_leave": "Leave",
"tribeinfo.unsaved_save": "Save",
"drawer.manage_tribe": "Manage",
"drawer.objects": "Objects",
"drawer.webapps": "Web apps",
@@ -180,6 +192,41 @@
"objects.search_title": "Search",
"objects.search_help_title": "Search Help",
"objects.search_btn": "Search",
"objects.items_title": "Items",
"objects.new_item": "New item",
"objects.new_item_hint": "Create item in {tribe}/{obj}",
"objects.schema_core_readonly": "read-only",
"objects.schema_core_hint": "Core schema, read-only for everyone (server-side). Items live in {tribe}/{obj}.",
"objects.schema_tribe_hint": "Items of {tribe}/{obj}, editable by tribe druids.",
"objects.items_count": "{{n}} item(s)",
"objects.items_more": "…and {{n}} more",
"objects.item_replace_confirm": "{{apxid}} already exists, replace?",
"objects.unsaved_schema_hint": "The schema has unsaved edits. Save it before closing?",
"objects.search_invalid_json": "Invalid JSON query",
"objects.search_hint_shapes": "Supported shapes: {\"txt\":\"...\",\"algo\":\"match\",\"fieldstring\":\"prop\"}, {\"indexfilter\":{\"key\":\"lst_id\"}}, {\"apxid\":[\"id1\"]}, {\"fields\":\"all\"}",
"objects.search_hint_condition": "{condition:[...]} is not supported — try {\"txt\":\"...\",\"algo\":\"match\",\"fieldstring\":\"prop\"} instead",
"objects.item_invalid_apxid": "{{prop}} (line {{line}}): identifier missing or empty",
"objects.item_missing_required": "{{prop}} (line {{line}}): required field missing",
"objects.item_wrong_type": "{{prop}} (line {{line}}): expected {{expected}}",
"objects.item_created": "Item created",
"objects.item_saved": "Item saved",
"objects.item_create_failed": "Failed to create item",
"objects.item_save_failed": "Failed to save item",
"objects.item_delete": "Delete",
"objects.item_delete_confirm": "Delete {{apxid}} permanently?",
"objects.item_deleted": "Item deleted",
"objects.item_delete_failed": "Failed to delete item",
"objects.item_not_found": "Item not found: {{obj}} / {{key}}",
"objects.item_not_found_tried": "Item not found: {{obj}} / {{key}} (tried: {{tried}})",
"objects.item_socle_has_variant": "\"{{key}}\" has language variants (e.g. {{variant}}). Create {{variant}} instead of a bare \"{{key}}\" to keep language variants coherent.",
"objects.usage": "Usage",
"objects.usage_title": "Usage of {{apxid}}",
"objects.usage_line": "{{obj}} · {{prop}} → {{n}} item(s): {{ids}}",
"objects.usage_none": "No references found",
"app.yes": "Yes",
"app.no": "No",
"objects.schema_invalid_missing": "Schema invalid: missing {{keys}}",
"objects.schema_is_item": "This looks like an item (id{{fields}}) — use the New item button in the Items section below, not the schema editor.",
"webapps.title": "Web Applications",
"webapps.empty": "No web applications yet",
"webapps.add_btn": "Add a web application",
@@ -213,10 +260,22 @@
"tribeinfo.backup_empty": "No objects in backup",
"tribeinfo.backup_manage": "Manage objects",
"objects.backup_title": "Backup",
"objects.backup_add": "Add schema",
"objects.backup_add": "Add object to backup",
"objects.backup_removed": "{{obj}} removed from backup",
"objects.backup_added": "{{obj}} added to backup",
"objects.backup_saved": "Backup list updated",
"objects.schema_create": "Create schema",
"objects.schema_create_placeholder": "New object name (A-Z, 0-9, _, -)",
"objects.schema_create_invalid": "Invalid name — use letters, digits, _ or - (e.g. test42)",
"objects.schema_create_exists": "Schema {{obj}} already exists",
"objects.schema_create_success": "Schema {{obj}} created — storage objects/{{obj}}/{itm,idx,conf.json} scaffolded",
"objects.schema_audit_title": "Schema change audit",
"objects.schema_audit_summary": "{{bad}} items with anomalies out of {{audited}} audited ({{total}} total)",
"objects.schema_audit_copy": "Copy report",
"objects.schema_audit_copied": "Report copied",
"objects.schema_audit_confirm": "Confirm new schema",
"objects.schema_audit_cancel": "Cancel",
"objects.schema_audit_removed": "{{prop}} (line {{line}}): field not in new schema",
"webapps.form_rename_subapp": "Rename sub-application",
"webapps.form_subapp_renamed": "Sub-application renamed from {{old}} to {{new}}",
"tribeinfo.invite_title": "Launch an invitation",
@@ -231,7 +290,6 @@
"webapps.form_delete_subapp_title": "Delete sub-application",
"webapps.form_delete_subapp_confirm": "Are you sure you want to delete '{name}'? All source code and associated files will be permanently deleted.",
"webapps.form_delete_subapp_btn": "Confirm deletion",
"webapps.form_draft_link": "🔗 Draft",
"webapps.form_build_prod": "Mettre à jour la prod",
"webapps.form_build_warning": "⚠ Build OK but source CSS could not be updated (permission). Run: {{cmd}}",
"webapps.form_build_error": "⚠ Build completed with errors for: {{names}}",
@@ -280,5 +338,28 @@
"files.acl_rules_body": "<p class=\"font-bold\">Targets</p><p class=\"pl-2 opacity-70\"><code>druids</code> — all tribe druids</p><p class=\"pl-2 opacity-70\"><code>profils:&lt;name&gt;</code> — members with that profile (e.g. profils:majors)</p><p class=\"pl-2 opacity-70\"><code>alias:&lt;alias&gt;</code> — a specific identity (e.g. alias:phil)</p><p class=\"font-bold mt-1\">Rights</p><p class=\"pl-2 opacity-70\"><code>R</code> — read</p><p class=\"pl-2 opacity-70\"><code>W</code> — write</p><p class=\"pl-2 opacity-70\"><code>R,W</code> — read + write</p><p class=\"font-bold mt-1\">Rules</p><p class=\"pl-2 opacity-70\">Rights are inherited: a rule on a folder applies to all its content.</p><p class=\"pl-2 opacity-70\">The deepest rule (longest path) wins.</p><p class=\"pl-2 opacity-70\">A druid always has full access (R+W).</p><p class=\"pl-2 opacity-70\">The owner of a file or folder always keeps R+W on it.</p><p class=\"pl-2 opacity-70\">Files with no known owner belong to <code>system</code> (created under application control).</p>",
"files.sig_valid": "✅ Valid signature",
"files.sig_corrupted": "❌ Corrupted signature (hash mismatch)",
"files.sig_unverifiable": "⚠ Cannot verify signature"
}
"files.sig_unverifiable": "⚠ Cannot verify signature",
"label": {
"nom": "Last name",
"prenom": "First name",
"genre": "Gender",
"titre": "Title",
"telephone": "Phone",
"mobile": "Mobile",
"fixe": "Landline",
"emailcom": "Email",
"dt_birth": "Date of birth",
"hobbies": "Interests",
"biography": "Biography",
"druidsinfo": "Druid notes",
"apxgain": "Monthly APX3 gain"
},
"objects.reindex": "Reindexer",
"objects.reindex_done": "Indexes rebuilt",
"objects.reindex_single_done": "Index {{index}} rebuilt",
"objects.reindex_skipped": "Nothing to rebuild",
"objects.reindex_failed": "Reindex failed",
"objects.item_rename_confirm": "Rename {{old}} to {{new}}?",
"objects.item_renamed": "Item {{old}} renamed to {{new}}",
"objects.item_rename_conflict": "Cannot rename {{old}}: {{new}} already exists"
}
+50 -1
View File
@@ -24,6 +24,9 @@
"members.profile_remove": "Quitar",
"members.profile_saved": "Perfil actualizado",
"members.profile_error": "Error al actualizar el perfil",
"members.profil_open": "Abrir en Miembros",
"members.profil_bar_title": "Archivos tribales",
"members.profil_unavailable": "Aplicación Miembros no disponible",
"drawer.mail": "Correo",
"members.filter_placeholder": "Filtrar miembros...",
"members.avatar_upload": "Subir avatar",
@@ -44,5 +47,51 @@
"label.apxgain": "Ganancias APX3 mensuales",
"label.druidsinfo": "Notas del druid",
"label.grants": "Derechos de acceso",
"webapps.open_app_com": "Abrir la subaplicación com"
"webapps.open_app_com": "Abrir la subaplicación com",
"objects.backup_add": "Añadir objeto al backup",
"objects.schema_create": "Crear esquema",
"objects.schema_create_placeholder": "Nombre del nuevo objeto (A-Z, 0-9, _, -)",
"objects.schema_create_invalid": "Nombre inválido — solo letras, dígitos, _ o - (p. ej. test42)",
"objects.schema_create_exists": "El esquema {{obj}} ya existe",
"objects.schema_create_success": "Esquema {{obj}} creado — almacenamiento objects/{{obj}}/{itm,idx,conf.json} inicializado",
"objects.schema_audit_title": "Auditoría del esquema antes de guardar",
"objects.schema_audit_summary": "{{bad}} elementos con anomalías de {{audited}} auditados ({{total}} en total)",
"objects.schema_audit_copy": "Copiar informe",
"objects.schema_audit_copied": "Informe copiado",
"objects.schema_audit_confirm": "Confirmar el nuevo esquema",
"objects.schema_audit_cancel": "Cancelar",
"objects.schema_audit_removed": "{{prop}} (línea {{line}}): campo ausente en el nuevo esquema",
"objects.items_count": "{{n}} elemento(s)",
"objects.items_more": "…y {{n}} más",
"objects.item_replace_confirm": "¿{{apxid}} ya existe, reemplazar?",
"tribeinfo.unsaved_save": "Guardar",
"objects.unsaved_schema_hint": "El esquema tiene cambios sin guardar. ¿Guardarlo antes de cerrar?",
"objects.search_invalid_json": "Consulta JSON no válida",
"objects.search_hint_shapes": "Formatos admitidos: {\"txt\":\"...\",\"algo\":\"match\",\"fieldstring\":\"prop\"}, {\"indexfilter\":{\"key\":\"lst_id\"}}, {\"apxid\":[\"id1\"]}, {\"fields\":\"all\"}",
"objects.search_hint_condition": "{condition:[...]} no admitido — usa {\"txt\":\"...\",\"algo\":\"match\",\"fieldstring\":\"prop\"}",
"objects.item_invalid_apxid": "{{prop}} (línea {{line}}): identificador ausente o vacío",
"objects.item_missing_required": "{{prop}} (línea {{line}}): falta el campo obligatorio",
"objects.item_wrong_type": "{{prop}} (línea {{line}}): se esperaba {{expected}}",
"objects.item_created": "Elemento creado",
"objects.item_saved": "Elemento guardado",
"objects.item_create_failed": "Error al crear el elemento",
"objects.item_save_failed": "Error al guardar el elemento",
"objects.item_delete": "Eliminar",
"objects.item_delete_confirm": "¿Eliminar definitivamente {{apxid}}?",
"objects.item_deleted": "Elemento eliminado",
"objects.item_delete_failed": "Error al eliminar el elemento",
"objects.usage": "Uso",
"objects.usage_title": "Uso de {{apxid}}",
"objects.usage_line": "{{obj}} · {{prop}} → {{n}} elemento(s): {{ids}}",
"objects.usage_none": "Sin referencias",
"app.yes": "Sí",
"app.no": "No",
"objects.reindex": "Reindexar",
"objects.reindex_done": "Índices reconstruidos",
"objects.reindex_single_done": "Índice {{index}} reconstruido",
"objects.reindex_skipped": "Nada que reconstruir",
"objects.reindex_failed": "Error al reindexar",
"objects.item_rename_confirm": "¿Renombrar {{old}} a {{new}}?",
"objects.item_renamed": "Elemento {{old}} renombrado a {{new}}",
"objects.item_rename_conflict": "No se puede renombrar {{old}}: {{new}} ya existe"
}
+95 -4
View File
@@ -83,6 +83,9 @@
"members.profile_remove": "Retirer",
"members.profile_saved": "Profil mis à jour",
"members.profile_error": "Erreur lors de la mise à jour du profil",
"members.profil_open": "Ouvrir dans Membres",
"members.profil_bar_title": "Dossiers tribaux",
"members.profil_unavailable": "Application Membres indisponible",
"repo.title": "Dépôt",
"repo.hint": "Configurez un dépôt Git pour cette tribu. Le premier « Pull & déployer » clone le dépôt dans le répertoire de code de la tribu ; les pulls suivants déploient les nouvelles versions.",
"repo.url_label": "URL du dépôt",
@@ -100,6 +103,7 @@
"repo.url_required": "L'URL du dépôt est requise",
"repo.error": "Erreur : {{msg}}",
"drawer.mail": "Courrier",
"drawer.contacts": "Contacts",
"members.filter_placeholder": "Filtrer les membres...",
"members.avatar_upload": "Charger un avatar",
"members.avatar_saved": "Avatar enregistré",
@@ -119,6 +123,13 @@
"label.apxgain": "Gains APX3 mensuels",
"label.druidsinfo": "Notes du druid",
"label.grants": "Droits d'accès",
"label.nom": "Nom",
"label.prenom": "Prénom",
"label.genre": "Genre",
"label.titre": "Titre",
"label.telephone": "Téléphone",
"label.mobile": "Mobile",
"label.fixe": "Ligne fixe",
"invite_req.success": "Demande d'invitation envoyée au druide de la tribu",
"invite_req.error": "Erreur lors de l'envoi de la demande",
"list.edit_btn": "Modifier",
@@ -152,6 +163,7 @@
"tribeinfo.unsaved_desc": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir quitter ?",
"tribeinfo.unsaved_stay": "Rester",
"tribeinfo.unsaved_leave": "Quitter",
"tribeinfo.unsaved_save": "Enregistrer",
"drawer.manage_tribe": "Gérer",
"drawer.objects": "Objets",
"drawer.webapps": "Web apps",
@@ -180,6 +192,51 @@
"objects.search_title": "Recherche",
"objects.search_help_title": "Aide Recherche",
"objects.search_btn": "Rechercher",
"objects.items_title": "Items",
"objects.new_item": "Nouvel item",
"objects.new_item_hint": "Créer un item dans {tribe}/{obj}",
"objects.schema_core_readonly": "lecture",
"objects.schema_core_hint": "Schéma core, lecture seule pour tous (côté serveur). Les items sont dans {tribe}/{obj}.",
"objects.schema_tribe_hint": "Items de {tribe}/{obj}, modifiables par les druids de la tribe.",
"objects.items_count": "{{n}} élément(s)",
"objects.items_more": "…et {{n}} de plus",
"objects.item_replace_confirm": "{{apxid}} existe déjà, remplacer ?",
"objects.unsaved_schema_hint": "Le schéma comporte des modifications non enregistrées. L'enregistrer avant de fermer ?",
"objects.search_invalid_json": "Requête JSON invalide",
"objects.search_hint_shapes": "Formes acceptées : {\"txt\":\"...\",\"algo\":\"match\",\"fieldstring\":\"prop\"}, {\"indexfilter\":{\"key\":\"lst_id\"}}, {\"apxid\":[\"id1\"]}, {\"fields\":\"all\"}",
"objects.search_hint_condition": "{condition:[...]} non supporté — utilisez {\"txt\":\"...\",\"algo\":\"match\",\"fieldstring\":\"prop\"}",
"objects.item_invalid_apxid": "{{prop}} (ligne {{line}}) : identifiant manquant ou vide",
"objects.item_missing_required": "{{prop}} (ligne {{line}}) : champ requis manquant",
"objects.item_wrong_type": "{{prop}} (ligne {{line}}) : {{expected}} attendu",
"objects.item_validation_error": "{{prop}} : {{detail}} (valeur {{value}}){{hint}}",
"objects.item_validation_bad_format": "format {{format}} invalide",
"objects.item_validation_bad_pattern": "motif {{pattern}} non respecté",
"objects.item_validation_bad_enum": "hors liste autorisée [{{enum}}]",
"objects.item_validation_bad_type": "type {{expected}} attendu",
"objects.item_validation_too_short": "trop court (min {{min}})",
"objects.item_validation_too_long": "trop long (max {{max}})",
"objects.item_validation_required": "champ requis manquant",
"objects.item_validation_hint_url": " — supprimez la propriété ou mettez une URL valide",
"objects.item_validation_hint_fix": " — corrigez la valeur et réessayez",
"objects.item_created": "Item créé",
"objects.item_saved": "Item enregistré",
"objects.item_create_failed": "Échec de la création de l'item",
"objects.item_save_failed": "Échec de l'enregistrement de l'item",
"objects.item_delete": "Supprimer",
"objects.item_delete_confirm": "Supprimer définitivement {{apxid}} ?",
"objects.item_deleted": "Item supprimé",
"objects.item_delete_failed": "Échec de la suppression de l'item",
"objects.item_not_found": "Item introuvable : {{obj}} / {{key}}",
"objects.item_not_found_tried": "Item introuvable : {{obj}} / {{key}} (essayés : {{tried}})",
"objects.item_socle_has_variant": "« {{key}} » a des variantes langue (ex. {{variant}}). Créez {{variant}} plutôt qu'un « {{key}} » nu pour garder la cohérence des variantes.",
"objects.usage": "Usage",
"objects.usage_title": "Usage de {{apxid}}",
"objects.usage_line": "{{obj}} · {{prop}} → {{n}} item(s) : {{ids}}",
"objects.usage_none": "Aucune référence trouvée",
"app.yes": "Oui",
"app.no": "Non",
"objects.schema_invalid_missing": "Schéma invalide : {{keys}} manquant(s)",
"objects.schema_is_item": "Ce contenu ressemble à un item (id{{fields}}) — utilisez le bouton Nouvel item de la section Items ci-dessous, pas l'éditeur de schéma.",
"webapps.title": "Applications Web",
"webapps.empty": "Aucune application web",
"webapps.add_btn": "Ajouter une application web",
@@ -213,10 +270,22 @@
"tribeinfo.backup_empty": "Aucun objet en backup",
"tribeinfo.backup_manage": "Gérer les objets",
"objects.backup_title": "Sauvegarde",
"objects.backup_add": "Ajouter un schéma",
"objects.backup_add": "Ajouter un objet aux backup",
"objects.backup_removed": "{{obj}} retiré de la sauvegarde",
"objects.backup_added": "{{obj}} ajouté à la sauvegarde",
"objects.backup_saved": "Liste de sauvegarde mise à jour",
"objects.schema_create": "Créer un schéma",
"objects.schema_create_placeholder": "Nom du nouvel objet (A-Z, 0-9, _, -)",
"objects.schema_create_invalid": "Nom invalide — lettres, chiffres, _ ou - uniquement (ex. test42)",
"objects.schema_create_exists": "Le schéma {{obj}} existe déjà",
"objects.schema_create_success": "Schéma {{obj}} créé — stockage objects/{{obj}}/{itm,idx,conf.json} initialisé",
"objects.schema_audit_title": "Audit du schéma avant enregistrement",
"objects.schema_audit_summary": "{{bad}} éléments avec anomalies sur {{audited}} audités ({{total}} au total)",
"objects.schema_audit_copy": "Copier le rapport",
"objects.schema_audit_copied": "Rapport copié",
"objects.schema_audit_confirm": "Confirmer le nouveau schéma",
"objects.schema_audit_cancel": "Annuler",
"objects.schema_audit_removed": "{{prop}} (ligne {{line}}) : champ absent du nouveau schéma",
"webapps.form_rename_subapp": "Renommer la sous-application",
"webapps.form_subapp_renamed": "Sous-application renommée de {{old}} à {{new}}",
"tribeinfo.invite_title": "Lancer une invitation",
@@ -231,7 +300,6 @@
"webapps.form_delete_subapp_title": "Supprimer la sous-application",
"webapps.form_delete_subapp_confirm": "Êtes-vous sûr de vouloir supprimer '{name}' ? Tout le code source et les fichiers associés seront définitivement supprimés.",
"webapps.form_delete_subapp_btn": "Confirmer la suppression",
"webapps.form_draft_link": "🔗 Draft",
"webapps.form_build_prod": "Mettre à jour la prod",
"webapps.form_build_warning": "⚠ Build OK mais le CSS source n'a pas pu être mis à jour (permission). Exécutez : {{cmd}}",
"webapps.form_build_error": "⚠ Build terminé avec erreurs pour : {{names}}",
@@ -280,5 +348,28 @@
"files.acl_rules_body": "<p class=\"font-bold\">Cibles</p><p class=\"pl-2 opacity-70\"><code>druids</code> — tous les druides de la tribu</p><p class=\"pl-2 opacity-70\"><code>profils:&lt;nom&gt;</code> — les membres ayant ce profil (ex. profils:majors)</p><p class=\"pl-2 opacity-70\"><code>alias:&lt;alias&gt;</code> — une identité précise (ex. alias:phil)</p><p class=\"font-bold mt-1\">Droits</p><p class=\"pl-2 opacity-70\"><code>R</code> — lecture</p><p class=\"pl-2 opacity-70\"><code>W</code> — écriture</p><p class=\"pl-2 opacity-70\"><code>R,W</code> — lecture + écriture</p><p class=\"font-bold mt-1\">Règles</p><p class=\"pl-2 opacity-70\">Les droits sont hérités : une règle sur un dossier s'applique à tout son contenu.</p><p class=\"pl-2 opacity-70\">La règle la plus profonde (chemin le plus long) gagne.</p><p class=\"pl-2 opacity-70\">Un druid a toujours accès complet (R+W).</p><p class=\"pl-2 opacity-70\">Le propriétaire d'un fichier ou répertoire garde toujours R+W dessus.</p><p class=\"pl-2 opacity-70\">Les fichiers sans propriétaire connu appartiennent à <code>system</code> (créés sous contrôle d'une application).</p>",
"files.sig_valid": "✅ Signature valide",
"files.sig_corrupted": "❌ Signature corrompue (hash mismatch)",
"files.sig_unverifiable": "⚠ Impossible de vérifier la signature"
}
"files.sig_unverifiable": "⚠ Impossible de vérifier la signature",
"label": {
"nom": "Last name",
"prenom": "First name",
"genre": "Gender",
"titre": "Title",
"telephone": "Phone",
"mobile": "Mobile",
"fixe": "Landline",
"emailcom": "Email",
"dt_birth": "Date of birth",
"hobbies": "Interests",
"biography": "Biography",
"druidsinfo": "Druid notes",
"apxgain": "Monthly APX3 gain"
},
"objects.reindex": "Reindex",
"objects.reindex_done": "Index reconstruits",
"objects.reindex_single_done": "Index {{index}} reconstruit",
"objects.reindex_skipped": "Rien à reconstruire",
"objects.reindex_failed": "Échec de la réindexation",
"objects.item_rename_confirm": "Renommer {{old}} en {{new}} ?",
"objects.item_renamed": "Item {{old}} renommé en {{new}}",
"objects.item_rename_conflict": "Impossible de renommer {{old}} : {{new}} existe déjà"
}
+50 -1
View File
@@ -24,6 +24,9 @@
"members.profile_remove": "Rimuovi",
"members.profile_saved": "Profilo aggiornato",
"members.profile_error": "Errore nell'aggiornamento del profilo",
"members.profil_open": "Apri in Membri",
"members.profil_bar_title": "Fascicoli tribali",
"members.profil_unavailable": "App Membri non disponibile",
"drawer.mail": "Posta",
"members.filter_placeholder": "Filtra membri...",
"members.avatar_upload": "Carica avatar",
@@ -44,5 +47,51 @@
"label.apxgain": "Guadagni APX3 mensili",
"label.druidsinfo": "Note del druid",
"label.grants": "Diritti di accesso",
"webapps.open_app_com": "Apri la sottoapplicazione com"
"webapps.open_app_com": "Apri la sottoapplicazione com",
"objects.backup_add": "Aggiungi oggetto al backup",
"objects.schema_create": "Crea schema",
"objects.schema_create_placeholder": "Nome del nuovo oggetto (A-Z, 0-9, _, -)",
"objects.schema_create_invalid": "Nome non valido — solo lettere, cifre, _ o - (es. test42)",
"objects.schema_create_exists": "Lo schema {{obj}} esiste già",
"objects.schema_create_success": "Schema {{obj}} creato — archiviazione objects/{{obj}}/{itm,idx,conf.json} predisposta",
"objects.schema_audit_title": "Controllo dello schema prima del salvataggio",
"objects.schema_audit_summary": "{{bad}} elementi con anomalie su {{audited}} controllati ({{total}} totali)",
"objects.schema_audit_copy": "Copia il rapporto",
"objects.schema_audit_copied": "Rapporto copiato",
"objects.schema_audit_confirm": "Conferma il nuovo schema",
"objects.schema_audit_cancel": "Annulla",
"objects.schema_audit_removed": "{{prop}} (riga {{line}}): campo assente nel nuovo schema",
"objects.items_count": "{{n}} elementi",
"objects.items_more": "…e altri {{n}}",
"objects.item_replace_confirm": "{{apxid}} esiste già, sostituire?",
"tribeinfo.unsaved_save": "Salva",
"objects.unsaved_schema_hint": "Lo schema ha modifiche non salvate. Salvarlo prima di chiudere?",
"objects.search_invalid_json": "Query JSON non valida",
"objects.search_hint_shapes": "Formati supportati: {\"txt\":\"...\",\"algo\":\"match\",\"fieldstring\":\"prop\"}, {\"indexfilter\":{\"key\":\"lst_id\"}}, {\"apxid\":[\"id1\"]}, {\"fields\":\"all\"}",
"objects.search_hint_condition": "{condition:[...]} non supportato — usa {\"txt\":\"...\",\"algo\":\"match\",\"fieldstring\":\"prop\"}",
"objects.item_invalid_apxid": "{{prop}} (riga {{line}}): identificativo mancante o vuoto",
"objects.item_missing_required": "{{prop}} (riga {{line}}): campo obbligatorio mancante",
"objects.item_wrong_type": "{{prop}} (riga {{line}}): atteso {{expected}}",
"objects.item_created": "Elemento creato",
"objects.item_saved": "Elemento salvato",
"objects.item_create_failed": "Creazione elemento non riuscita",
"objects.item_save_failed": "Salvataggio elemento non riuscito",
"objects.item_delete": "Elimina",
"objects.item_delete_confirm": "Eliminare definitivamente {{apxid}}?",
"objects.item_deleted": "Elemento eliminato",
"objects.item_delete_failed": "Eliminazione elemento non riuscita",
"objects.usage": "Utilizzo",
"objects.usage_title": "Utilizzo di {{apxid}}",
"objects.usage_line": "{{obj}} · {{prop}} → {{n}} elemento(i): {{ids}}",
"objects.usage_none": "Nessun riferimento trovato",
"app.yes": "Sì",
"app.no": "No",
"objects.reindex": "Reindicizza",
"objects.reindex_done": "Indici ricostruiti",
"objects.reindex_single_done": "Indice {{index}} ricostruito",
"objects.reindex_skipped": "Nulla da ricostruire",
"objects.reindex_failed": "Reindicizzazione non riuscita",
"objects.item_rename_confirm": "Rinominare {{old}} in {{new}}?",
"objects.item_renamed": "Elemento {{old}} rinominato in {{new}}",
"objects.item_rename_conflict": "Impossibile rinominare {{old}}: {{new}} esiste già"
}
+50 -1
View File
@@ -24,6 +24,9 @@
"members.profile_remove": "Verwijderen",
"members.profile_saved": "Profiel bijgewerkt",
"members.profile_error": "Fout bij het bijwerken van het profiel",
"members.profil_open": "Openen in Leden",
"members.profil_bar_title": "Stambestanden",
"members.profil_unavailable": "Leden-app niet beschikbaar",
"drawer.mail": "Post",
"members.filter_placeholder": "Leden filteren...",
"members.avatar_upload": "Avatar uploaden",
@@ -44,5 +47,51 @@
"label.apxgain": "Maandelijkse APX3-winsten",
"label.druidsinfo": "Druïde-notities",
"label.grants": "Toegangsrechten",
"webapps.open_app_com": "De com-subtoepassing openen"
"webapps.open_app_com": "De com-subtoepassing openen",
"objects.backup_add": "Object aan backup toevoegen",
"objects.schema_create": "Schema aanmaken",
"objects.schema_create_placeholder": "Nieuwe objectnaam (A-Z, 0-9, _, -)",
"objects.schema_create_invalid": "Ongeldige naam — alleen letters, cijfers, _ of - (bijv. test42)",
"objects.schema_create_exists": "Schema {{obj}} bestaat al",
"objects.schema_create_success": "Schema {{obj}} aangemaakt — opslag objects/{{obj}}/{itm,idx,conf.json} ingericht",
"objects.schema_audit_title": "Schema-audit vóór opslaan",
"objects.schema_audit_summary": "{{bad}} items met afwijkingen van {{audited}} gecontroleerde ({{total}} totaal)",
"objects.schema_audit_copy": "Rapport kopiëren",
"objects.schema_audit_copied": "Rapport gekopieerd",
"objects.schema_audit_confirm": "Nieuw schema bevestigen",
"objects.schema_audit_cancel": "Annuleren",
"objects.schema_audit_removed": "{{prop}} (regel {{line}}): veld ontbreekt in het nieuwe schema",
"objects.items_count": "{{n}} items",
"objects.items_more": "…en nog {{n}}",
"objects.item_replace_confirm": "{{apxid}} bestaat al, vervangen?",
"tribeinfo.unsaved_save": "Opslaan",
"objects.unsaved_schema_hint": "Het schema heeft niet-opgeslagen wijzigingen. Opslaan voor je sluit?",
"objects.search_invalid_json": "Ongeldige JSON-query",
"objects.search_hint_shapes": "Ondersteunde vormen: {\"txt\":\"...\",\"algo\":\"match\",\"fieldstring\":\"prop\"}, {\"indexfilter\":{\"key\":\"lst_id\"}}, {\"apxid\":[\"id1\"]}, {\"fields\":\"all\"}",
"objects.search_hint_condition": "{condition:[...]} niet ondersteund — gebruik {\"txt\":\"...\",\"algo\":\"match\",\"fieldstring\":\"prop\"}",
"objects.item_invalid_apxid": "{{prop}} (regel {{line}}): identificatie ontbreekt of is leeg",
"objects.item_missing_required": "{{prop}} (regel {{line}}): verplicht veld ontbreekt",
"objects.item_wrong_type": "{{prop}} (regel {{line}}): {{expected}} verwacht",
"objects.item_created": "Item aangemaakt",
"objects.item_saved": "Item opgeslagen",
"objects.item_create_failed": "Item aanmaken mislukt",
"objects.item_save_failed": "Item opslaan mislukt",
"objects.item_delete": "Verwijderen",
"objects.item_delete_confirm": "{{apxid}} definitief verwijderen?",
"objects.item_deleted": "Item verwijderd",
"objects.item_delete_failed": "Item verwijderen mislukt",
"objects.usage": "Gebruik",
"objects.usage_title": "Gebruik van {{apxid}}",
"objects.usage_line": "{{obj}} · {{prop}} → {{n}} item(s): {{ids}}",
"objects.usage_none": "Geen verwijzingen gevonden",
"app.yes": "Ja",
"app.no": "Nee",
"objects.reindex": "Opnieuw indexeren",
"objects.reindex_done": "Indexen opnieuw opgebouwd",
"objects.reindex_single_done": "Index {{index}} opnieuw opgebouwd",
"objects.reindex_skipped": "Niets opnieuw op te bouwen",
"objects.reindex_failed": "Opnieuw indexeren mislukt",
"objects.item_rename_confirm": "{{old}} hernoemen naar {{new}}?",
"objects.item_renamed": "Item {{old}} hernoemd naar {{new}}",
"objects.item_rename_conflict": "Kan {{old}} niet hernoemen: {{new}} bestaat al"
}
-3
View File
@@ -26,9 +26,6 @@ self.addEventListener('install', function (event) {
self.addEventListener('fetch', function (event) {
if (event.request.method !== 'GET') return;
if (event.request.url.indexOf('/draft/') !== -1) {
return event.respondWith(fetch(event.request));
}
event.respondWith(
(async function () {
try {
+6 -1
View File
@@ -17,7 +17,7 @@
<script src="/cdn/dev/lib/bip39/bip39.js"></script>
<script src="/cdn/dev/lib/qr-code-styling/lib/qr-code-styling.js"></script>
<script src="/cdn/dev/lib/jsQR/dist/jsQR.js"></script>
<script src="/cdn/dev/lib/apxtri/draft.js"></script>
<script src="/cdn/dev/lib/apxtri/inspect.js"></script>
<script src="/cdn/dev/lib/apxtri/auth.js"></script>
<script src="/cdn/dev/lib/apxtri/menu.js"></script>
<script src="/cdn/dev/lib/apxtri/localdb.js"></script>
@@ -157,6 +157,11 @@
</div>
</div>
<script src="/cdn/dev/lib/apxtri/notify.js"></script>
<div id="inspect-debug" class="hidden fixed bottom-2 right-2 z-50 bg-neutral text-neutral-content text-xs px-2 py-1 rounded font-mono opacity-70">
<div id="inspect-screen">screen: dashboard</div>
<div id="inspect-action" class="hidden"></div>
</div>
<script src="js/app.js"></script>
</body>
</html>
-1
View File
@@ -16,7 +16,6 @@ self.addEventListener('install', event => {
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
if (event.request.url.includes('/draft/')) return event.respondWith(fetch(event.request));
event.respondWith((async () => {
try {
const networkResponse = await fetch(event.request);
File diff suppressed because one or more lines are too long
+1
View File
@@ -68,3 +68,4 @@ function resolveTribeFromHost() {
}
module.exports = resolveTribeFromHost;
module.exports.resolveTribeFromHostHeader = resolveTribeFromHostHeader;
+137 -35
View File
@@ -16,6 +16,13 @@ const DOMAIN = process.env.DOMAIN || "";
// declared explicitly here so the CA in use never depends on that default.
const ACME_DEFAULT_CA = "https://acme-v02.api.letsencrypt.org/directory";
// RFC 9110 token: the characters a header field-name may be built from. Caddy
// turns each key of a `headers` handler `response.set` map into a response
// header name, so a name outside this set would make the whole generated config
// invalid — and an invalid config is rejected at reload, i.e. a town-wide
// outage rather than one degraded webapp.
const HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
const log = (message, ...args) => {
if (process.env.ACTIVELOG?.split(',').includes('Caddy')) {
console.log(`[Caddy] ${message}`, ...args);
@@ -27,6 +34,118 @@ const log = (message, ...args) => {
*/
class Caddy {
/**
* Reads the OPTIONAL per-webapp response headers declared in an appconf.json.
*
* Declared shape (a webapp's own appconf.json, or a mainapp subapp's):
* "security": {
* "headers": {
* "Content-Security-Policy": "default-src 'none'; …",
* "Referrer-Policy": "same-origin",
* "X-Content-Type-Options": "nosniff",
* "X-Frame-Options": "SAMEORIGIN",
* "Permissions-Policy": "geolocation=(), …"
* }
* }
*
* Any header name is accepted on purpose: the block is a plain name/value
* map, so a webapp that has been audited can carry its CSP *and* the
* companion headers (Referrer-Policy, X-Content-Type-Options,
* X-Frame-Options next to frame-ancestors, Permissions-Policy,
* Strict-Transport-Security, …) from a single declaration.
*
* Nothing declared — the case for every webapp that has not been audited —
* yields {}, and applySecurityHeaders() then returns the handler chain
* untouched. That is what makes the mechanism safe to roll out one webapp
* at a time: enabling it cannot change the response of any other webapp.
*
* A name that is not an RFC 9110 token, or a value containing CR or LF, is
* dropped with a warning rather than emitted: both would make the generated
* config invalid and take the town down at reload.
*
* @param {object} conf Parsed appconf.json (may be null/undefined)
* @param {string} label Human-readable "tribe/webapp" label for the log
* @returns {Object<string,string>} Sanitised header name -> value map (possibly empty)
*/
static parseSecurityHeaders(conf, label) {
const declared = conf && conf.security && conf.security.headers;
if (!declared || typeof declared !== "object" || Array.isArray(declared)) return {};
const out = {};
for (const [name, value] of Object.entries(declared)) {
if (!HEADER_NAME_RE.test(name)) {
console.warn(`[Caddy] ${label}: security.headers name "${name}" is not a valid header field-name, dropped`);
continue;
}
const headerValue = String(value == null ? "" : value).replace(/^[\s]+|[\s]+$/g, "");
if (!headerValue) {
console.warn(`[Caddy] ${label}: security.headers "${name}" has an empty value, dropped`);
continue;
}
if (/[\r\n]/.test(headerValue)) {
console.warn(`[Caddy] ${label}: security.headers "${name}" contains CR or LF, dropped`);
continue;
}
out[name] = headerValue;
}
if (Object.keys(out).length) {
log(`${label}: security headers declared -> ${Object.keys(out).join(', ')}`);
}
return out;
}
/**
* Prepends the Caddy `headers` handler for a webapp's declared security
* headers to a route handler chain, or returns the chain untouched when the
* webapp declares none.
*
* The handler must come FIRST: Caddy's `headers` handler wraps every
* handler that follows it in the same route, so putting it after the
* `file_server` would have it decorate nothing.
*
* @param {Object<string,string>} headers Name -> value map from parseSecurityHeaders()
* @param {Array<object>} handlers The route's existing handler chain
* @returns {Array<object>} The handler chain to use
*/
static applySecurityHeaders(headers, handlers) {
const names = Object.keys(headers || {});
if (!names.length) return handlers;
const set = {};
for (const name of names) set[name] = [headers[name]];
return [{ handler: "headers", response: { set } }, ...handlers];
}
/**
* Reads a mainapp subapp's OPTIONAL appconf.json.
*
* A subapp is not a webapp of its own: it lives inside the town mainapp and
* has no code directory, so it declares its per-app configuration (the
* security headers today) in its own appconf.json. The SOURCE copy is
* preferred so a versioned declaration wins over the served one, with the
* runtime dist as fallback for a subapp whose declaration was placed there
* directly. `Wwws.buildpwa` writes named files into the dist and never
* removes the directory, so a declaration living in the dist survives a
* rebuild.
*
* @param {string} subapp Subapp directory name (e.g. "mymail")
* @returns {object|null} Parsed appconf.json, or null when there is none
*/
static readSubappAppconf(subapp) {
const candidates = [
path.join(getWebappSource(TOWN, "mainapp"), subapp, "appconf.json"),
path.join(DATA_PATH, TOWN, "wwws", "mainapp", subapp, "appconf.json")
];
for (const confPath of candidates) {
if (!fs.existsSync(confPath)) continue;
try {
return JSON.parse(fs.readFileSync(confPath, "utf8"));
} catch (error) {
console.warn(`[Caddy] malformed appconf.json for subapp ${subapp} (${confPath}): ${error.message}`);
}
}
return null;
}
/**
* Retrieves the public and local network IP addresses.
*/
@@ -133,7 +252,8 @@ class Caddy {
dns,
runtimePath,
sourcePath,
allowedOrigins: conf.allowedOrigins || []
allowedOrigins: conf.allowedOrigins || [],
securityHeaders: Caddy.parseSecurityHeaders(conf, `${tribe}/${webapp}`)
});
log(`Discovered webapp: ${webapp} (tribe: ${tribe}, dns: ${dns})`);
@@ -474,6 +594,13 @@ class Caddy {
const notConflict = conflictingHosts.length
? { not: [{ host: conflictingHosts }] }
: {};
// Optional per-subapp security headers (appconf.json `security.headers`).
// Applied to the DOCUMENT route only, never to the town mainapp site
// block below: that block matches `host: [DOMAIN]` with no path, so it
// also serves `/`, and a header put there would leak onto every town
// page. The `/subapp` 301 carries no document either, so it is left alone.
const subappHeaders = Caddy.parseSecurityHeaders(
Caddy.readSubappAppconf(subapp), `${TOWN}/mainapp/${subapp}`);
// Redirect /subapp → /subapp/ (fixes relative path resolution)
routes.push({
match: [{ path: [`/${subapp}`], ...notConflict }],
@@ -481,19 +608,14 @@ class Caddy {
headers: { Location: [`/${subapp}/`] } }],
terminal: true
});
// Draft: serve source through Express so /cdn/dev/ → /cdn/{town}/
// (and xtribe/xnation metas) are rewritten, matching buildpwa.
routes.push({
match: [{ path: [`/${subapp}/draft`, `/${subapp}/draft/`, `/${subapp}/draft/*`], ...notConflict }],
handle: [{ handler: "reverse_proxy", upstreams: [{ dial: `${process.env.BACKENDURL}:${process.env.APIPORT}` }] }],
terminal: true
});
// Serve /subapp/ and /subapp/*
routes.push({
match: [{ path: [`/${subapp}/`, `/${subapp}/*`], ...notConflict }],
handle: [{ handler: "rewrite", strip_path_prefix: `/${subapp}` },
{ handler: "file_server", root: subappPath,
index_names: ["index.html", "index_fr.html", "index_en.html"] }],
handle: Caddy.applySecurityHeaders(subappHeaders, [
{ handler: "rewrite", strip_path_prefix: `/${subapp}` },
{ handler: "file_server", root: subappPath,
index_names: ["index.html", "index_fr.html", "index_en.html"] }
]),
terminal: true
});
log(`Subapp route: /${subapp}/* → ${subappPath}`);
@@ -514,14 +636,14 @@ class Caddy {
}
routes.push({
match: [{ path: [`/${w.name}/*`] }],
handle: [{
handle: Caddy.applySecurityHeaders(w.securityHeaders, [{
handler: "rewrite",
strip_path_prefix: `/${w.name}`
}, {
handler: "file_server",
root: w.runtimePath,
index_names: ["index.html", "index_fr.html", "index_en.html"]
}],
}]),
terminal: true
});
});
@@ -596,33 +718,13 @@ class Caddy {
terminal: true
});
}
// Draft: serve a host-based subapp's source through Express so
// /cdn/dev/ -> /cdn/{tribe}/ and the xtribe/xnation metas are
// rewritten, matching buildpwa. Without this, /{subapp}/draft/* is
// served by the file_server below and draft.js cannot resolve.
let hostSubapps = [];
try {
hostSubapps = fs.readdirSync(w.runtimePath)
.filter(n => fs.existsSync(path.join(w.runtimePath, n, 'manifest.json')));
} catch (e) { /* webapp not provisioned yet */ }
hostSubapps.forEach(subapp => {
routes.push({
match: [{ host: [w.dns], path: [`/${subapp}/draft`, `/${subapp}/draft/`, `/${subapp}/draft/*`] }],
handle: [{
handler: "reverse_proxy",
upstreams: [{ dial: `${process.env.BACKENDURL}:${process.env.APIPORT}` }],
headers: { request: { set: { "X-Apxtri-Webapp": [w.name] } } }
}],
terminal: true
});
});
routes.push({
match: [{ host: [w.dns] }],
handle: [{
handle: Caddy.applySecurityHeaders(w.securityHeaders, [{
handler: "file_server",
root: w.runtimePath,
index_names: ["index.html", "index_fr.html", "index_en.html"]
}],
}]),
terminal: true
});
});
+1282
View File
File diff suppressed because it is too large Load Diff
+148 -2
View File
@@ -24,10 +24,15 @@ const DATAPATH = process.env.DATAPATH;
const NODEPATH = process.env.NODEPATH;
const CRON_IDENTIFIER = "mailbox-cron";
const DEFAULT_POLL_MIN = 5;
// DaisyUI badge colors allowed for mailbox availableTags. Shared by the write path
// (setAvailableTags) and the read projection (_boxPublic) so both cannot drift.
const TAG_COLORS = ["primary", "secondary", "accent", "success", "warning", "error", "info", "neutral"];
// In-process sync locks, keyed by mailboxid: {alias, since}. A lock older than
// SYNC_LOCK_STALE_MIN is considered stale and reclaimed instead of blocking.
const SYNC_LOCKS = new Map();
const SYNC_LOCK_STALE_MIN = 10;
// Hard cap for a buffer materialized by addFetchedAttachment (20 MB).
const ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024;
class Mailboxes {
/**
@@ -282,6 +287,8 @@ class Mailboxes {
const password = (data && (data.password || (data.secrets && data.secrets.password))) || "";
if (!password) return { error: "missing_password", connector: null, secrets };
secrets.password = password;
const smtpPassword = (data && (data.smtpPassword || (data.secrets && data.secrets.smtpPassword))) || "";
if (smtpPassword) secrets.smtpPassword = smtpPassword;
return { connector, secrets, user: connector.user };
}
return { connector: {}, secrets, user: data.email || "" };
@@ -538,6 +545,22 @@ class Mailboxes {
connector,
};
const result = await mailconn.testProviderConnection(testMailbox, secrets);
// Echo the tested parameters (never the password) so a failed provider test
// is self-explanatory. SMTP auth user/port/tls are surfaced too.
const params = {
provider: data.provider,
tribe: data.tribe,
email: data.email || user || "",
connector: { ...connector },
};
if (params.connector.smtp) {
params.connector.smtp = {
host: connector.smtp.host,
port: connector.smtp.port,
tls: connector.smtp.tls,
secure: connector.smtp.port === 465,
};
}
return {
status: result.ok ? 200 : 400,
ref: "Mailboxes",
@@ -549,6 +572,7 @@ class Mailboxes {
smtp: result.smtp || null,
smtpError: result.smtp && typeof result.smtp === "string" ? result.smtp : null,
reason: result.error || null,
params,
},
};
} catch (error) {
@@ -557,10 +581,31 @@ class Mailboxes {
}
}
/**
* Defensive normalisation of mailbox availableTags for API consumers: always an
* array of {key, label, color}. Malformed entries are dropped and a missing or
* unknown color falls back to "primary", so hand-edited mailbox files can never
* leak a broken badge. Mirrors the sanitisation of setAvailableTags.
* @private
*/
static _tagsPublic(tags) {
if (!Array.isArray(tags)) return [];
const out = [];
for (const t of tags) {
if (!t || !t.key || !t.label) continue;
const key = String(t.key).trim().toLowerCase();
const label = String(t.label).trim();
if (!key || !label) continue;
out.push({ key, label, color: TAG_COLORS.includes(t.color) ? t.color : "primary" });
}
return out;
}
/**
* Public shape of a mailbox for API consumers (no secrets, no auth internals).
* Exposes the raw 'syncing' marker plus a derived isSyncing boolean, true only while
* a non-stale sync is running (see _isSyncStale).
* a non-stale sync is running (see _isSyncStale). Also exposes the druid-managed
* availableTags so tag UIs can resolve mail tag keys to a label and a badge color.
* @private
*/
static _boxPublic(item) {
@@ -590,6 +635,7 @@ class Mailboxes {
counts: item.counts || { total: 0, unread: 0 },
connected: item.connected === true,
lastTest: item.lastTest || null,
availableTags: this._tagsPublic(item.availableTags),
connector: publicConnector,
};
}
@@ -881,7 +927,7 @@ class Mailboxes {
? tags.filter((t) => t && t.key && t.label).map((t) => ({
key: String(t.key).trim().toLowerCase(),
label: String(t.label).trim(),
color: ["primary", "secondary", "accent", "success", "warning", "error", "info", "neutral"].includes(t.color) ? t.color : "primary",
color: TAG_COLORS.includes(t.color) ? t.color : "primary",
}))
: [];
const result = await mailboxDb.update({ ...mailbox, availableTags: safeTags }, { xalias: role.xalias, xprofils: ["druids"] });
@@ -1370,6 +1416,106 @@ class Mailboxes {
}
}
/**
* Materialize an already downloaded file as an attachment of a mail: the buffer is
* written to objects/mails/files/{emailId}/{NN}_{storageName} and a descriptor is
* appended to item.attachments. Requires reader right on the mail's mailbox.
* Idempotent on (origin, sourceUrl): when the mail already carries an attachment
* referencing the same upstream resource nothing is written and the existing entry
* is returned, so a mail is never downloaded or stored twice.
* @param {Object} role Session role
* @param {String} tribe Tribe holding the mail
* @param {String} emailid Mail identifier (emailId)
* @param {Object} payload {filename, mimeType, buffer, origin, sourceUrl}
* @returns {{status,ref,msg,data:{emailId,index,filename,contentType,size,storageName,path}}}
*/
static async addFetchedAttachment(role, tribe, emailid, payload) {
try {
const safeId = this._emailIdSafe(emailid);
const itemPath = path.join(DATAPATH, tribe, "objects", "mails", "itm", `${safeId}.json`);
if (!(await fs.pathExists(itemPath))) {
return { status: 404, ref: "Mailboxes", msg: "mail_not_found", data: { emailid } };
}
const item = await fs.readJson(itemPath);
const mailbox = await this._loadMailbox(item.mailboxid);
if (!mailbox || !(await this._rightFor(role, mailbox))) {
return { status: 403, ref: "Mailboxes", msg: "right_required", data: { right: "read" } };
}
const p = payload || {};
const filename = typeof p.filename === "string" ? p.filename.trim() : "";
const mimeType = typeof p.mimeType === "string" ? p.mimeType.trim() : "";
const buffer = p.buffer;
const hasBuffer =
!!buffer &&
(Buffer.isBuffer(buffer) || buffer instanceof Uint8Array) &&
typeof buffer.length === "number" &&
buffer.length > 0;
if (!filename || !mimeType || !hasBuffer) {
return { status: 400, ref: "Mailboxes", msg: "attachment_invalid", data: { emailid } };
}
const size = buffer.length;
if (size > ATTACHMENT_MAX_BYTES) {
return {
status: 400,
ref: "Mailboxes",
msg: "attachment_too_large",
data: { emailid, size, max: ATTACHMENT_MAX_BYTES },
};
}
const origin = typeof p.origin === "string" ? p.origin.trim() : "";
const sourceUrl = typeof p.sourceUrl === "string" ? p.sourceUrl.trim() : "";
if (!Array.isArray(item.attachments)) item.attachments = [];
if (origin || sourceUrl) {
const seen = item.attachments.findIndex(
(a) => a && (a.origin || "") === origin && (a.sourceUrl || "") === sourceUrl
);
if (seen !== -1) {
const existing = item.attachments[seen];
return {
status: 200,
ref: "Mailboxes",
msg: "attachment_exists",
data: {
emailId: safeId,
index: seen,
filename: existing.filename,
storageName: existing.storageName,
existing: true,
},
};
}
}
const index = item.attachments.length;
const dir = this._attachmentDir(item);
const storageName = await this._safeAttachmentName({ ...item, filename }, mimeType, index);
await fs.ensureDir(dir);
await fs.outputFile(path.join(dir, storageName), buffer);
const descriptor = { filename, mimeType, size, storageName };
if (origin) descriptor.origin = origin;
if (sourceUrl) descriptor.sourceUrl = sourceUrl;
item.attachments.push(descriptor);
item.hasAttachments = true;
await fs.outputJson(itemPath, item, { spaces: 2 });
return {
status: 200,
ref: "Mailboxes",
msg: "attachment_added",
data: {
emailId: safeId,
index,
filename,
contentType: mimeType,
size,
storageName,
path: path.join(dir, storageName),
},
};
} catch (error) {
console.error("[Mailboxes] addFetchedAttachment error:", error);
return { status: 500, ref: "Mailboxes", msg: "internal_error", error: error.message };
}
}
/**
* Compute {total, unread} counts of a mailbox within its attached tribe.
* @private
+750
View File
@@ -0,0 +1,750 @@
/**
* @file models/Mailcontent.js
* @description Server-side HTML allow-list sanitiser and text/plain generator for OUTBOUND
* mail bodies (models/Outbox.js drafts, models/mailconn.js sendMessage).
*
* ## Why this module exists
*
* The compose body is posted by the PWA and used to travel to nodemailer completely
* unfiltered: routes/outbox.js accepted `@apiBody {String} [html]`, Outbox.createDraft stored
* `String(data.html)` verbatim, schema/outbox.json declared `html` as a bare string, and
* mailconn.sendMessage handed it to `transport.sendMail()` verbatim. The only HTML filtering in
* the tree was the client-side DENY-list regex `composeSanitizeHtml()` in mainapp/mymail, applied
* to the quoted original only. A deny list is the wrong shape (every unknown construct is a
* hole) and a client-side filter is not a trust boundary. The server owns an ALLOW-list.
*
* ## Parsing
*
* Uses jsdom (already a declared dependency in package.json, so no new dependency is added),
* whose HTML parser is parse5: a spec-compliant tree builder with full error recovery. It is a
* real parser, not a regex chain, so nesting and malformed markup are handled by construction
* (unclosed tags are closed, misnested tags are re-nested, stray end tags are ignored, comments
* and doctypes are distinct node types). A regex chain could not make those guarantees.
*
* ## Output contract
*
* - Always an HTML FRAGMENT. `<html>`, `<head>`, `<body>` and `<!doctype>` are never emitted:
* only the children of `document.body` are walked, and those element names are not allowed.
* - Idempotent: `sanitiseHtml(sanitiseHtml(x)) === sanitiseHtml(x)`. See the unittest.
* - A `text/plain` alternative is always derivable (see prepare / htmlToText).
*
* ## Behaviour of an element that is not on the allow-list
*
* Two distinct behaviours, chosen deliberately:
*
* 1. DROP_WITH_CONTENT — the element *and its subtree* disappear. Reserved for elements whose
* content is not prose, or that carry a capability: script/style/iframe/object/embed/svg/
* math/canvas/media/form controls, and the raw-text containers title/xmp/plaintext. Their
* text is code, markup, form fields or a payload, never something a recipient should read as
* the body of the message. `form`, `textarea`, `select` and `input` are in here on purpose:
* the frontend already denies them outright (composeEmailElementDeny), so no legitimate
* compose body can contain them, and dropping a `<form>`'s subtree stops a quoted mail from
* dumping a nested layout table plus fields into the answer.
*
* 2. UNWRAP — the tags are dropped, the children are kept. This is the default for every other
* unknown element: `html`, `head`, `body` (document wrappers), and anything a mail client or
* an upstream editor may invent — Word's `<o:p>`, `<st1:place>`, `<apxtri-quote>`, a custom
* element. It is safe because an unknown element survives only as its text children: the tag
* is gone, so it cannot act as a container, cannot carry an attribute, and cannot select or
* be selected by anything. It is also the behaviour that avoids mangling legitimate prose
* that merely happens to sit inside a tag this module has never heard of.
*
* jsdom requires the module lazily (see _parser): loading it costs ~0.4 s and only a body that
* is actually being sanitised needs it.
*/
// Third-party, required lazily by _parser() to keep startup cost off every request.
let JSDOM = null;
/** Longest HTML body kept, mirrored by maxLength in schema/outbox.json. */
const MAX_HTML_LENGTH = 262144; // 256 KiB
/** Longest text/plain body kept, mirrored by maxLength in schema/outbox.json. */
const MAX_TEXT_LENGTH = 131072; // 128 KiB
/**
* Hard cap applied to the raw input before parsing. body-parser accepts a 500 MB JSON body, so
* the sanitiser must not hand an unbounded string to the parser.
*/
const MAX_INPUT_LENGTH = 4 * MAX_HTML_LENGTH;
/** Allowed elements. A superset of what mainapp/mymail composeEmailHtml() can emit. */
const ALLOWED_ELEMENTS = new Set([
"p", "br", "div", "span",
"b", "strong", "i", "em", "u", "s", "strike", "del", "sub", "sup",
"a", "ul", "ol", "li", "blockquote",
"h1", "h2", "h3", "h4", "h5", "h6", "hr",
"table", "thead", "tbody", "tfoot", "tr", "td", "th", "caption", "col", "colgroup",
"pre", "code", "font", "small", "big", "center",
"dl", "dt", "dd",
]);
/** Elements dropped together with their subtree. See the file header for the rationale. */
const DROP_WITH_CONTENT = new Set([
// active content and remote resource loaders
"script", "style", "iframe", "frame", "frameset", "object", "embed", "applet",
"svg", "math", "canvas", "audio", "video", "source", "track", "img", "picture",
"link", "meta", "base", "bgsound", "keygen", "param", "portal", "map", "area",
// form machinery: a mail body carries no interactive form
"form", "input", "button", "select", "option", "optgroup", "textarea", "fieldset", "datalist",
// content that is never prose
"noscript", "template", "title", "xmp", "plaintext", "marquee",
"dialog", "menu", "menuitem",
]);
/**
* Attributes allowed on every allowed element. `style` is kept on purpose: inline CSS is
* exactly what an email needs. `role` is inert (an ARIA rendering hint, no scripting) but the
* frontend's 600px wrapper table carries role="presentation" and must survive untouched.
*/
const GLOBAL_ATTRIBUTES = new Set(["style", "dir", "lang", "align", "valign", "title", "role"]);
/** Extra attributes allowed per tag, on top of GLOBAL_ATTRIBUTES. */
const ELEMENT_ATTRIBUTES = {
a: ["href", "target", "rel", "name"],
blockquote: ["cite", "type"],
table: ["width", "height", "cellpadding", "cellspacing", "border", "bgcolor"],
thead: ["bgcolor"],
tbody: ["bgcolor"],
tfoot: ["bgcolor"],
tr: ["bgcolor"],
td: ["width", "height", "colspan", "rowspan", "bgcolor", "nowrap", "scope"],
th: ["width", "height", "colspan", "rowspan", "bgcolor", "nowrap", "scope"],
col: ["width", "span", "bgcolor"],
colgroup: ["width", "span", "bgcolor"],
hr: ["width", "size", "color", "noshade"],
font: ["color", "face", "size"],
ol: ["start", "type", "reversed"],
ul: ["type"],
};
/**
* Attributes carrying a URI. `href` and `cite` are the only two that can survive the allow-list
* (every other one belongs to a dropped element), but the scheme check is written generically so
* that widening the element allow-list can never silently open a URI hole.
*/
const URI_ATTRIBUTES = new Set([
"href", "src", "background", "action", "xlink:href", "poster", "formaction",
"data", "cite", "longdesc", "ping",
]);
/** URI schemes an outbound mail body may reference. Anything else is dropped. */
const SAFE_URI_SCHEMES = new Set(["http", "https", "mailto", "tel", "sms", "ftp", "cid", "webcal", "bitcoin"]);
/** Inline CSS properties an email may rely on. Anything else is dropped. */
const ALLOWED_CSS_PROPERTIES = new Set([
// colour and background
"color", "background", "background-color",
// typography
"font", "font-family", "font-size", "font-style", "font-variant", "font-weight",
"line-height", "letter-spacing", "word-spacing",
"text-align", "text-decoration", "text-indent", "text-overflow", "text-transform",
"text-shadow", "white-space", "word-break", "word-wrap", "overflow-wrap",
"direction", "unicode-bidi",
// box
"width", "min-width", "max-width", "height", "min-height", "max-height",
"margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
"padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
"border", "border-top", "border-right", "border-bottom", "border-left",
"border-width", "border-style", "border-color",
"border-top-width", "border-right-width", "border-bottom-width", "border-left-width",
"border-top-style", "border-right-style", "border-bottom-style", "border-left-style",
"border-top-color", "border-right-color", "border-bottom-color", "border-left-color",
"border-radius", "border-top-left-radius", "border-top-right-radius",
"border-bottom-left-radius", "border-bottom-right-radius",
"border-collapse", "border-spacing", "box-shadow", "box-sizing",
"list-style", "list-style-type", "list-style-position",
"vertical-align", "display", "opacity", "overflow", "table-layout", "caption-side",
"empty-cells", "-webkit-text-size-adjust", "-webkit-font-smoothing",
]);
/**
* Layout properties dropped before the allow-list is even consulted. Most of them are not in
* ALLOWED_CSS_PROPERTIES either; this list is the belt to that pair of braces, so widening the
* allow-list later cannot reintroduce them by accident. Mirrors composeEmailStyleDeny() in the
* frontend.
*/
const DENIED_CSS_PROPERTIES = new Set([
"position", "top", "right", "bottom", "left", "z-index", "float", "clear",
"flex", "flex-basis", "flex-direction", "flex-flow", "flex-grow", "flex-shrink", "flex-wrap",
"order", "grid", "grid-area", "grid-auto-flow", "grid-auto-rows", "grid-auto-columns",
"grid-column", "grid-column-gap", "grid-column-start", "grid-column-end",
"grid-gap", "grid-row", "grid-row-gap", "grid-row-start", "grid-row-end",
"grid-template", "grid-template-areas", "grid-template-columns", "grid-template-rows",
"gap", "row-gap", "column-gap",
"align-content", "align-items", "align-self", "justify-content", "justify-items",
"justify-self", "place-content", "place-items", "place-self",
"transform", "transform-origin", "transform-style", "perspective", "backface-visibility",
"transition", "animation", "will-change",
]);
/** CSS property prefixes denied wholesale (same rationale as DENIED_CSS_PROPERTIES). */
const DENIED_CSS_PREFIXES = ["flex-", "grid-", "align-", "justify-", "place-", "transform-"];
/** Values that make a declaration fetch a remote resource or execute code. */
const DENIED_CSS_VALUE = /(?:url\s*\(|expression\s*\(|javascript\s*:|vbscript\s*:|-moz-binding|@import|behaviou?r\s*:|\/\*|\*\/|[<>\\])/i;
/** `display` values an email client cannot honour; the visible face of a flex/grid layout. */
const DENIED_DISPLAY_VALUE = /^(?:inline-)?(?:flex|grid)$|^contents$/i;
/** Text nodes are escaped per the HTML fragment serialisation algorithm. */
const escapeText = (value) =>
String(value).replace(/&/g, "&amp;").replace(/\u00a0/g, "&nbsp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
/** Attribute values are escaped per the HTML fragment serialisation algorithm. */
const escapeAttr = (value) =>
String(value).replace(/&/g, "&amp;").replace(/\u00a0/g, "&nbsp;").replace(/"/g, "&quot;");
/** Block-level elements: a newline is inserted before and after their content. */
const BLOCK_ELEMENTS = new Set([
"p", "div", "blockquote", "pre", "ul", "ol", "dl", "dt", "dd",
"h1", "h2", "h3", "h4", "h5", "h6",
"table", "thead", "tbody", "tfoot", "tr", "caption", "colgroup", "center",
]);
/**
* Deepest element nesting `_walk` will descend into.
*
* The walk is recursive, so nesting depth is bounded by the JavaScript call stack, and a body of
* nothing but nested tags is exactly the input that reaches it: `<blockquote>` repeated 20,000
* times parses fine and then overflows the stack while being serialised. A RangeError escaping the
* sanitiser would turn a hostile body into a failed send, which is the availability problem the
* never-throw contract exists to avoid. Real mail layout is a handful of levels deep — the
* frontend wrapper is table > tr > td > block — so 100 is far above any legitimate body and well
* below anything that hurts. Deeper subtrees are dropped whole: unwrapping them would recurse
* just as deep, and a body nested 100 levels down is not content worth keeping.
*/
const MAX_WALK_DEPTH = 100;
/** Allowed elements the HTML parser gives no children and that get no end tag. */
const VOID_ELEMENTS = new Set(["br", "hr", "col"]);
/** Table cells are separated by a space, not by a line break, so a table reads as one line. */
const CELL_ELEMENTS = new Set(["td", "th"]);
class Mailcontent {
/**
* The HTML parser, loaded on first use. jsdom defaults are the safe ones and are relied on:
* `runScripts` is not set (so no `<script>` ever executes) and `resources` is not set (so no
* subresource is ever fetched). The cost is paid once per process.
* @private
*/
static _parser() {
if (!JSDOM) ({ JSDOM } = require("jsdom"));
return JSDOM;
}
/** Longest HTML body accepted, mirrored by maxLength in schema/outbox.json. */
static get MAX_HTML_LENGTH() {
return MAX_HTML_LENGTH;
}
/** Longest text/plain body accepted, mirrored by maxLength in schema/outbox.json. */
static get MAX_TEXT_LENGTH() {
return MAX_TEXT_LENGTH;
}
/**
* Coerce any input to the string a parser can be handed, and cap it so an oversized body is
* never parsed. Never throws.
* @private
*/
static _input(value) {
if (value === undefined || value === null) return "";
let text = typeof value === "string" ? value : String(value);
if (text.length > MAX_INPUT_LENGTH) text = text.slice(0, MAX_INPUT_LENGTH);
return this._shallow(text);
}
/**
* Cut an input short where its tag nesting passes MAX_WALK_DEPTH.
*
* This is a fast-reject pre-filter, not the limit itself. jsdom builds its tree with a
* recursive `_descendantAdded` walk, so a body of nothing but nested tags makes the *parser*
* overflow the call stack: `new JSDOM()` throws a RangeError after tens of seconds of work, and
* _document() turning that into a silently empty body is both slow and a worse outcome than
* keeping the part of the body that was never deep. Counting nesting first means the deep tail
* is dropped in linear time and the rest of the body survives.
*
* It is a coarse scan of tag tokens, not a parser: it can over-count (a `<` inside an attribute
* value, an unclosed tag) and so cut a legitimate body short. That is the safe direction — the
* authoritative bound is still MAX_WALK_DEPTH inside _walk, and anything that slips past both
* this scan and that guard is caught by _document.
* @param {string} html - Input about to be parsed.
* @returns {string} The input, or its first MAX_WALK_DEPTH levels.
* @private
*/
static _shallow(html) {
// Comments and declarations start with `<!`, doctypes with `<!`, and neither matches a name.
const token = /<(\/?)([a-zA-Z][a-zA-Z0-9]*)([^>]*)>/g;
const open = [];
let match = token.exec(html);
while (match !== null) {
const [full, slash, name, rest] = match;
const lower = name.toLowerCase();
if (slash) {
// Close: drop back to the matching name, or ignore it if the tag was never opened.
const at = open.lastIndexOf(lower);
if (at >= 0) open.length = at;
} else if (!rest.trimEnd().endsWith("/") && !VOID_ELEMENTS.has(lower)) {
open.push(lower);
if (open.length > MAX_WALK_DEPTH) {
return html.slice(0, match.index);
}
}
match = token.exec(html);
}
return html;
}
/**
* The elements a cut at `cut` leaves open, in document order.
*
* `spans` is in document order, so the open set is exactly the elements whose byte span
* straddles the cut: started at or before it, and whose end tag the cut does not yet finish.
* @param {Array<{start:number, end:number, tag:string}>} spans - Every element emitted.
* @param {number} cut - Byte offset the fragment would be cut at.
* @returns {Array<{start:number, end:number, tag:string}>} The open elements.
* @private
*/
static _openAt(spans, cut) {
const open = [];
for (const span of spans) {
if (span.start <= cut && cut < span.end) open.push(span);
}
return open;
}
/**
* End tags for the elements a cut at `cut` leaves open, so the truncated fragment is what the
* parser would have produced. Without this, truncation can emit `<p>` where the parser emits
* `<p></p>`, and sanitise(sanitise(x)) would differ from sanitise(x).
* @private
*/
static _closingTags(spans, cut) {
const open = this._openAt(spans, cut);
// Collected in document order, so written back to front: innermost first, as the parser
// writes them. Nesting guarantees a parent starts before each of its children.
let tail = "";
for (let i = open.length - 1; i >= 0; i -= 1) tail += `</${open[i].tag}>`;
return tail;
}
/**
* The highest offset at or below `budget` where the fragment can be cut without corrupting
* anything a client would re-interpret: never inside a tag, never inside an entity.
*
* Cutting anywhere in a text run is safe — text is escaped, so there is no markup to open — and
* it matters: refusing to cut mid-text would collapse a 512 KiB single-paragraph body down to
* the single `<p>` that opens it, because its closing tag is the only tag boundary in range.
* @param {string} html - Serialised fragment.
* @param {number} budget - Highest acceptable offset.
* @returns {number} The cut offset, or -1 when there is none.
* @private
*/
static _safeCut(html, budget) {
if (budget < 0) return -1;
let cut = Math.min(budget, html.length - 1);
// Inside a tag: a `<` after the last `>` at or before the cut means the cut sits in markup.
// It steps back to just before that `<`, which is the previous valid position — the end of
// the text run in front of the tag. Stepping back to the *previous* `>` instead would skip
// over that whole text run, which is exactly the run worth keeping.
while (cut >= 0 && html.lastIndexOf("<", cut) > html.lastIndexOf(">", cut)) {
cut = html.lastIndexOf("<", cut) - 1;
}
if (cut < 0) return -1;
// Inside an entity: an `&` with no `;` after it in the kept head is a partial `&amp;` or
// `&nbsp;`, which a client would render as literal text. Drop the `&` itself.
const head = html.slice(0, cut + 1);
const amp = head.lastIndexOf("&");
if (amp >= 0 && head.indexOf(";", amp) < 0) return amp - 1;
return cut;
}
/**
* Cut a serialised fragment to `max` characters, closing the elements the cut leaves open.
*
* The end tags need room of their own, so the cut starts at the limit and walks left until the
* head plus the end tags fit. Every step moves the cut strictly left, so this terminates; in
* practice it stops within the length of those end tags, i.e. a handful of positions. A fragment
* with no workable cut is dropped rather than returned half-open.
* @param {string} html - Serialised fragment.
* @param {Array<{start:number, end:number, tag:string}>} spans - Every element emitted, with the
* byte offsets its start and end tags occupy.
* @param {number} max - Maximum length of the returned string.
* @returns {string} Fragment of at most `max` characters, or "" when nothing can be kept.
* @private
*/
static _truncateHtml(html, spans, max) {
if (html.length <= max) return html;
let cut = this._safeCut(html, max - 1);
while (cut >= 0) {
const tail = this._closingTags(spans, cut);
if (cut + 1 + tail.length <= max) return html.slice(0, cut + 1) + tail;
cut = this._safeCut(html, cut - 1);
}
return "";
}
/**
* True when a `tbody` holds nothing but `tr` children, i.e. nothing an HTML parser would not
* insert itself.
*
* Such a tbody is normalised away, which is what keeps the sanitiser byte-identical to the
* 600px wrapper composeEmailHtml() emits (`<table ...><tr><td>` — no tbody). Dropping it is
* render-neutral: no mail client distinguishes an explicit tbody holding only rows from the
* implied one, and every parser re-inserts it on the next parse.
*
* The caller combines this with the *serialised* attribute string being empty. Testing the
* attributes here instead was not idempotent: `<tbody id="x">` loses the id to the attribute
* filter, so pass 1 kept the tbody, pass 2 dropped it. Only the post-filter state is stable.
*
* @private
*/
static _isRowsOnlyGroup(element) {
const parent = element.parentElement;
if (!parent || parent.tagName.toLowerCase() !== "table") return false;
const kids = element.childNodes;
if (!kids.length) return false;
// childNodes is a NodeList: index it rather than calling array methods on it.
for (let i = 0; i < kids.length; i += 1) {
if (kids[i].nodeType !== 1 || kids[i].tagName.toLowerCase() !== "tr") return false;
}
return true;
}
/**
* Escape a plain-text string so it can be embedded in HTML as text. Needed wherever plain
* text is interpolated into a body (Outbox._commitSentMail falls back to `<pre>text</pre>`
* when a draft has no HTML: without it, a body reading "1 < 2" would open a bogus tag).
* @param {string} value - Plain text.
* @returns {string} HTML-escaped text.
*/
static escapeHtml(value) {
return escapeText(value === undefined || value === null ? "" : value);
}
/**
* True when a value carries any character at all beyond whitespace. Used by the callers to
* tell "the caller sent nothing" from "the caller sent something the allow-list emptied".
* @param {string} value
* @returns {boolean}
*/
static hasContent(value) {
return this._input(value).trim() !== "";
}
/**
* Scheme check for a URI-bearing attribute. Whitespace and control characters are removed
* first: `jav&#x09;ascript:` and a leading newline are decoded by the parser and then
* stripped here, so the classic obfuscated-scheme bypasses cannot slip past.
* Returns the URI to keep, or "" when it must be dropped.
* @private
*/
static _safeUri(value) {
const cleaned = String(value).replace(/[\x00-\x20\x7f]/g, "");
if (!cleaned) return "";
const hash = cleaned.indexOf("#");
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(cleaned);
// A fragment-only reference stays inside the message; everything else needs an allowed scheme.
if (!scheme) return hash === 0 ? cleaned : "";
if (!SAFE_URI_SCHEMES.has(scheme[1].toLowerCase())) return "";
return cleaned;
}
/**
* One inline CSS declaration, kept or dropped. Property names are matched in lower case
* (CSS is case-insensitive for them); the value is preserved verbatim otherwise, so the
* frontend's canonical `prop:value` output round-trips unchanged.
* @private
*/
static _cssDeclaration(declaration) {
const raw = String(declaration || "").trim();
const separator = raw.indexOf(":");
if (separator < 1) return "";
const property = raw.slice(0, separator).trim().toLowerCase();
const value = raw.slice(separator + 1).trim();
if (!property || !value) return "";
if (DENIED_CSS_PROPERTIES.has(property)) return "";
if (DENIED_CSS_PREFIXES.some((prefix) => property.startsWith(prefix))) return "";
if (!ALLOWED_CSS_PROPERTIES.has(property)) return "";
// DENIED_CSS_VALUE also rejects <, > and backslash: a CSS escape (\75 rl) must not be able
// to smuggle a url() past the literal check.
if (DENIED_CSS_VALUE.test(value)) return "";
if (property === "display" && DENIED_DISPLAY_VALUE.test(value)) return "";
return `${property}:${value}`;
}
/**
* Re-filter an inline style attribute with the CSS allow-list, and drop the attribute when
* nothing survives so no empty style="" is emitted.
*
* The declarations are rejoined with ";" in their source order. A trailing ";" is reproduced
* when the input carried one: the frontend is not consistent about it (composeEmailTag() emits
* none, the 600px wrapper of composeEmailHtml() ends with one), and preserving the caller's
* choice is what keeps the server output byte-identical to the composer's.
* @private
*/
static _cssStyle(value) {
const raw = String(value);
const kept = raw
.split(";")
.map((declaration) => this._cssDeclaration(declaration))
.filter(Boolean);
if (!kept.length) return "";
const trailing = /;\s*$/.test(raw) ? ";" : "";
return kept.join(";") + trailing;
}
/**
* The allowed attributes of one element, in source order, already escaped and quoted.
* Everything else is dropped: every on* handler (covered by the allow-list), class, id,
* data-*, aria-*, formaction, background and any other attribute not listed above.
* @private
*/
static _attributes(element, tag) {
const allowed = ELEMENT_ATTRIBUTES[tag] || [];
const out = [];
for (const attribute of element.attributes) {
const name = attribute.name.toLowerCase();
if (!GLOBAL_ATTRIBUTES.has(name) && !allowed.includes(name)) continue;
let value = attribute.value;
if (name === "style") {
value = this._cssStyle(value);
if (!value) continue;
} else if (URI_ATTRIBUTES.has(name)) {
value = this._safeUri(value);
if (!value) continue;
}
out.push(` ${name}="${escapeAttr(value)}"`);
}
return out.join("");
}
/**
* Walk the children of `node` and append the sanitised serialisation to `out.text`, recording
* the byte span each emitted element occupies in `spans`.
* @param {Node} node - Parent node whose children are serialised.
* @param {{text: string}} out - String accumulator, grown in place.
* @param {Array<{start:number, end:number, tag:string}>} spans - Every element emitted. Entries
* are appended, never popped: _truncateHtml must know what a cut at any offset leaves open,
* and that question can only be answered from the full history.
* @param {number} depth - Current nesting level, bounded by MAX_WALK_DEPTH.
* @private
*/
static _walk(node, out, spans, depth) {
for (const child of node.childNodes) {
// 3 = text, 1 = element. Comment (8) and doctype (10) nodes are never emitted, which is
// how comments are stripped and why no <!doctype> can reach the output.
if (child.nodeType === 3) {
out.text += escapeText(child.data);
continue;
}
if (child.nodeType !== 1) continue;
const tag = child.tagName.toLowerCase();
if (DROP_WITH_CONTENT.has(tag)) continue;
if (depth >= MAX_WALK_DEPTH) continue;
if (!ALLOWED_ELEMENTS.has(tag)) {
// Unwrap: the tag goes away, its children are still walked.
this._walk(child, out, spans, depth + 1);
continue;
}
const attributes = this._attributes(child, tag);
// Attribute filtering has already run, so this decision uses the state that will actually
// be written out: a tbody left with no attribute and only rows is the implied one.
if (tag === "tbody" && !attributes && this._isRowsOnlyGroup(child)) {
// The element is normalised away but its rows are the content: walk into it.
this._walk(child, out, spans, depth + 1);
continue;
}
// br / hr / col are void: the parser gives them no children and they get no end tag.
if (child.childNodes.length === 0 && VOID_ELEMENTS.has(tag)) {
out.text += `<${tag}${attributes}>`;
continue;
}
// The span opens before the children are walked and closes on the last byte of the end tag,
// so `start`/`end` bound exactly the bytes this element occupies in the serialised string.
// `end` is the offset of that last byte, not one past it: a cut landing on the `>` of
// `</tag>` has already closed the element, and only a cut before it has not.
const span = { start: out.text.length, end: 0, tag };
spans.push(span);
out.text += `<${tag}${attributes}>`;
this._walk(child, out, spans, depth + 1);
out.text += `</${tag}>`;
span.end = out.text.length - 1;
}
}
/**
* Parse an HTML string into a jsdom Document, or null when it cannot be parsed. Never throws:
* a sanitiser that throws on the input it is meant to defend against would be an availability
* problem, and a mail body is never worth one.
* @private
*/
static _document(html) {
try {
return new (this._parser())(html).window.document;
} catch (e) {
return null;
}
}
/**
* Sanitise an HTML string against the element / attribute / CSS allow-lists.
*
* Always returns an HTML fragment (no <html>, <head>, <body> or <!doctype>), with comments
* stripped, unsafe elements removed (with their content where they are dangerous), unknown
* harmless elements unwrapped, inline styles re-filtered, and every URI reduced to an allowed
* scheme. The result is truncated to MAX_HTML_LENGTH, on a text or tag boundary and never
* leaving a tag or an entity half-written, with the elements the cut leaves open closed again.
*
* Idempotent: sanitiseHtml(sanitiseHtml(x)) === sanitiseHtml(x).
*
* @param {string} value - Raw HTML. Any type is accepted; null/undefined give "".
* @returns {string} Sanitised HTML fragment.
*/
static sanitiseHtml(value) {
const source = this._input(value);
if (!source.trim()) return "";
const document = this._document(source);
if (!document) return "";
// A string accumulator rather than an array of fragments: `_truncateHtml` needs the byte
// offset of every open start tag, and that is only meaningful against the joined string.
const out = { text: "" };
const spans = [];
this._walk(document.body, out, spans, 0);
return this._truncateHtml(out.text.trim(), spans, MAX_HTML_LENGTH);
}
/**
* Append the plain-text rendering of a node to `out`. Walking the parsed tree is what
* guarantees "no residual tags": a tag can only be emitted by this module if it is written
* here, and none is. Entities need no decoding pass either, the parser already decoded them.
* @param {Node} node - Parent node whose children are converted.
* @param {string[]} out - Text accumulator, grown in place.
* @param {number} depth - Current nesting level, bounded by MAX_WALK_DEPTH for the same
* call-stack reason as _walk.
* @private
*/
static _walkText(node, out, depth) {
for (const child of node.childNodes) {
if (child.nodeType === 3) {
out.push(child.data);
continue;
}
if (child.nodeType !== 1) continue;
if (depth >= MAX_WALK_DEPTH) continue;
const tag = child.tagName.toLowerCase();
if (DROP_WITH_CONTENT.has(tag)) continue;
if (!ALLOWED_ELEMENTS.has(tag)) {
this._walkText(child, out, depth + 1);
continue;
}
if (tag === "br") {
out.push("\n");
continue;
}
if (tag === "hr") {
out.push("\n---\n");
continue;
}
if (tag === "li") {
out.push("\n- ");
this._walkText(child, out, depth + 1);
continue;
}
if (tag === "tbody" && this._isRowsOnlyGroup(child)) {
this._walkText(child, out, depth + 1);
continue;
}
if (CELL_ELEMENTS.has(tag)) {
out.push(" ");
this._walkText(child, out, depth + 1);
continue;
}
if (BLOCK_ELEMENTS.has(tag)) {
out.push("\n");
this._walkText(child, out, depth + 1);
out.push("\n");
continue;
}
this._walkText(child, out, depth + 1);
}
}
/**
* The plain-text alternative of an HTML body: block-level newlines, decoded entities,
* collapsed whitespace, no residual tag. Mirrors composeEmailText() in the frontend, but
* reads the parsed tree instead of running regexes over the markup.
* Truncated to MAX_TEXT_LENGTH, so the result always satisfies the text/plain bound in
* schema/outbox.json. This is what makes prepare() safe: a caller who sends only an oversized
* HTML body still gets a text alternative the schema accepts, instead of one Odmdb rejects.
* @param {string} html - HTML, sanitised or not (it is walked as parsed).
* @returns {string} Plain text, at most MAX_TEXT_LENGTH characters.
*/
static htmlToText(html) {
const source = this._input(html);
if (!source.trim()) return "";
const document = this._document(source);
if (!document) return "";
const out = [];
this._walkText(document.body, out, 0);
let text = out.join("");
text = text.replace(/\r\n?/g, "\n");
// Collapse horizontal whitespace only: the line structure just produced must survive.
text = text.replace(/[^\S\n]+/g, " ");
text = text.split("\n").map((line) => line.trim()).join("\n");
text = text.replace(/\n{3,}/g, "\n\n").trim();
if (text.length <= MAX_TEXT_LENGTH) return text;
// Cut on a line boundary where there is one, so the alternative does not end mid-sentence.
let cut = text.lastIndexOf("\n", MAX_TEXT_LENGTH);
if (cut <= 0) cut = MAX_TEXT_LENGTH;
text = text.slice(0, cut);
// Do not leave half a surrogate pair behind: an astral character is two UTF-16 units.
if (/[\uD800-\uDBFF]/.test(text.slice(-1))) text = text.slice(0, -1);
return text.trim();
}
/**
* Sanitise a text/plain body. It is a MIME part, never a header block, but it is still
* normalised so the stored value is deterministic and cannot carry a CR: CRLF and LF / CR are
* folded to LF (which removes every CR-LF injection sequence from the stored document), and
* the other C0 control characters are dropped.
*
* Lines that look like headers ("Subject: ...", "X-Anything: ...") are deliberately KEPT: in a
* body part they are inert prose, and the frontend itself writes exactly such lines when the
* user forwards a message (composeForwardHeader). Stripping them would silently corrupt
* legitimate content to defend against an attack that this field does not enable.
*
* @param {string} value - Raw text. Any type is accepted; null/undefined give "".
* @returns {string} Sanitised plain text, truncated to MAX_TEXT_LENGTH.
*/
static sanitiseText(value) {
let text = this._input(value).replace(/\r\n?/g, "\n");
// Keep tab and newline, drop every other C0 control and DEL.
text = text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
text = text.replace(/\n{4,}/g, "\n\n\n");
return text.trim().slice(0, MAX_TEXT_LENGTH);
}
/**
* Normalise the two bodies of an outbound mail, guaranteeing a text/plain alternative.
*
* - `html` is sanitised to an allow-listed fragment.
* - `text` is sanitised as a MIME part.
* - when `text` is absent or blank and `html` carries content, `text` is derived from the
* sanitised HTML, so an HTML-only body can never reach nodemailer as `text: ""`.
*
* Idempotent, and safe to call on an already-normalised pair.
*
* @param {object} data - {html, text}
* @returns {{html: string, text: string}} Both bodies, sanitised, text never blank when html is not.
*/
static prepare(data) {
const input = data || {};
const html = this.sanitiseHtml(input.html);
let text = this.sanitiseText(input.text);
if (!text.trim() && html) text = this.htmlToText(html);
return { html, text };
}
}
module.exports = Mailcontent;
+118 -5
View File
@@ -9,6 +9,40 @@ const DATA_PATH = process.env.DATAPATH;
const TRIBE_NAME = process.env.TOWN;
const log = process.env.ACTIVELOG?.split(',').includes('Notifications');
/**
* Top-level mail fields serialized into the MIME header block. A CR or LF in
* any of them is a header-injection / recipient-redirection vector.
* `html` and `text` are deliberately absent: their newlines are legitimate
* body content and are encoded by the MIME part writer, not the header block.
*/
const MAILHEADERFIELDS = [
"from",
"to",
"cc",
"bcc",
"replyTo",
"sender",
"subject",
"references",
"inReplyTo",
"messageId",
"contentType",
];
/**
* Attachment fields forwarded to nodemailer, all of which end up in MIME
* headers (`Content-Type`, `Content-Disposition`, `Content-ID`,
* `Content-Transfer-Encoding`) or in the attachment filename.
*/
const MAILATTACHMENTFIELDS = [
"filename",
"contentType",
"contentEncoding",
"contentDisposition",
"cid",
"path",
];
/**
* Manages notifications like emails and mailing list registrations.
*/
@@ -93,6 +127,75 @@ class Notifications {
}
}
/**
* Detects CR or LF in a value destined for a mail header.
* Handles every shape nodemailer accepts for an address or header value:
* a string, an array of addresses, or an `{ name, address }` object.
* @param {*} value - Candidate header value
* @returns {boolean} true when a CR or LF is present
*/
static hascrlf(value) {
if (value === null || value === undefined) return false;
if (typeof value === "string") return /[\r\n]/.test(value);
if (Array.isArray(value)) return value.some((entry) => this.hascrlf(entry));
if (typeof value === "object") {
return Object.values(value).some((entry) =>
typeof entry === "string" ? /[\r\n]/.test(entry) : this.hascrlf(entry)
);
}
return false;
}
/**
* Fails closed on CR/LF injection in the header-bearing fields of a mail.
* Nodemailer folds stray CR/LF in most standard fields, but an address such
* as `victim@example.com\r\nBcc: attacker@evil.tld` is still parsed into an
* extra envelope recipient, so any occurrence must abort the send.
* @param {object} data - Message object passed to nodemailer
* @returns {object|null} Error result when a field is tainted, otherwise null
*/
static checkmailheaders(data) {
if (!data || typeof data !== "object") return null;
const fields = [];
for (const field of MAILHEADERFIELDS) {
if (this.hascrlf(data[field])) fields.push(field);
}
if (data.envelope && typeof data.envelope === "object") {
for (const key of Object.keys(data.envelope)) {
if (this.hascrlf(data.envelope[key])) fields.push(`envelope.${key}`);
}
}
if (data.headers && typeof data.headers === "object") {
for (const key of Object.keys(data.headers)) {
if (/[\r\n]/.test(key) || this.hascrlf(data.headers[key])) {
fields.push(`headers.${key}`);
}
}
}
if (Array.isArray(data.attachments)) {
data.attachments.forEach((attachment, index) => {
if (!attachment || typeof attachment !== "object") return;
for (const field of MAILATTACHMENTFIELDS) {
if (this.hascrlf(attachment[field])) {
fields.push(`attachments[${index}].${field}`);
}
}
});
}
if (fields.length === 0) return null;
return {
status: 400,
ref: "Notifications",
msg: "email_header_injection_rejected",
data: { fields },
};
}
/**
* Sends an email using nodemailer.
*/
@@ -113,6 +216,14 @@ class Notifications {
}
data.from = fromAddress;
// Validate the effective message, after the from-address override, so both
// route input and any injected value are covered before touching SMTP.
const injection = this.checkmailheaders(data);
if (injection) {
console.error(`[Notifications] Email rejected, CR/LF found in:`, injection.data.fields);
return injection;
}
try {
const transporter = nodemailer.createTransport(smtpConfig);
const result = await transporter.sendMail(data);
@@ -132,14 +243,16 @@ class Notifications {
* Manages a bulk email campaign.
*/
static async manageemail(data, templateName, tribe, lg, profils) {
const tplPath = path.resolve(__dirname, `../${tribe}/template/${templateName}_${lg}.js`);
if (!(await fs.pathExists(tplPath))) {
return { status: 404, ref: "Notifications", msg: "template_not_found", data: { template: tplPath } };
const Templates = require("./Templates.js");
const tplPath = Templates.resolvePath(tribe, templateName, lg);
if (!tplPath) {
return { status: 404, ref: "Notifications", msg: "template_not_found", data: { tribe, template: `${templateName}_${lg}` } };
}
const tplEmail = require(tplPath);
const profList = Array.isArray(profils) ? profils : (profils ? [profils] : []);
// Check permissions
if (tplEmail.allowedprofils && !profils.some(p => tplEmail.allowedprofils.includes(p) || p === 'druid')) {
if (tplEmail.allowedprofils && !profList.some(p => tplEmail.allowedprofils.includes(p) || p === 'druid')) {
return { status: 403, ref: "Notifications", msg: "permission_denied_for_template" };
}
@@ -161,4 +274,4 @@ class Notifications {
}
}
module.exports = Notifications;
module.exports = Notifications;
+263 -11
View File
@@ -219,6 +219,96 @@ class Odmdb {
return { hasPermission, allowedFields };
}
/**
* Resolves a (possibly nested) value from an object using dot notation.
* @private
* @param {object} obj - Source object.
* @param {string} pathStr - Dot-separated path (e.g. "address.city").
* @returns {*} The resolved value or null when missing.
*/
_getNested(obj, pathStr) {
if (!obj || !pathStr) return null;
return pathStr.split('.').reduce((o, k) => (o && o[k] !== undefined) ? o[k] : null, obj);
}
/**
* Compares two index values with normalized JSON (sorted keys, sorted arrays).
* @private
* @param {*} a - First value.
* @param {*} b - Second value.
* @returns {boolean} True when values are equal.
*/
_isEqualIndexVal(a, b) {
const norm = (v) => {
if (Array.isArray(v)) {
return v.map(norm).sort((x, y) => JSON.stringify(x).localeCompare(JSON.stringify(y)));
}
if (v && typeof v === 'object') {
const o = {};
Object.keys(v).sort().forEach((k) => { o[k] = norm(v[k]); });
return o;
}
return v;
};
return JSON.stringify(norm(a)) === JSON.stringify(norm(b));
}
/**
* Returns the names of indexes impacted by a change from oldItem to finalItem.
* An index is dirty when its keyval value differs, its filter evaluation
* differs, a view objkeys field differs, or the apxid differs.
* @private
* @param {object} oldItem - Item before update.
* @param {object} finalItem - Item after update.
* @returns {string[]} Dirty index names.
*/
_getDirtyIndexes(oldItem, finalItem) {
if (!this.schema || !this.schema.apxidx || this.schema.apxidx.length === 0) return [];
const apxidKey = this.schema.apxid;
if (oldItem[apxidKey] !== finalItem[apxidKey]) {
return this.schema.apxidx.map((i) => i.name);
}
const dirty = [];
for (const idx of this.schema.apxidx) {
let isDirty = false;
const oldVal = this._getNested(oldItem, idx.keyval);
const newVal = this._getNested(finalItem, idx.keyval);
if (!this._isEqualIndexVal(oldVal, newVal)) {
isDirty = true;
}
if (!isDirty && idx.filter) {
const f = String(idx.filter).replace(/;/g, "");
try {
let itm = oldItem;
const oldKeep = eval(f);
itm = finalItem;
const newKeep = eval(f);
if (!!oldKeep !== !!newKeep) isDirty = true;
} catch (e) {
logger.warn(`Dirty check filter eval failed for index '${idx.name}': ${e.message}`);
}
}
if (!isDirty && idx.type === 'view') {
const objkeys = idx.objkeys || (idx.objkey ? [idx.objkey] : []);
for (const k of objkeys) {
if (!this._isEqualIndexVal(oldItem[k], finalItem[k])) { isDirty = true; break; }
}
}
if (isDirty) dirty.push(idx.name);
}
return dirty;
}
/**
* Checks whether an item exists by its key ID.
* @param {string} keyid - The unique ID of the item.
* @returns {Promise<boolean>} True when the item file exists.
*/
async isExist(keyid) {
const itemPath = path.join(this.objPath, "itm", `${keyid}.json`);
return fs.pathExists(itemPath);
}
/**
* Creates a new item.
* @param {object} item - The item data to create.
@@ -303,6 +393,7 @@ class Odmdb {
const itemPath = path.join(this.objPath, "itm", `${keyid}.json`);
try {
await fs.ensureDir(path.join(this.objPath, "itm"));
await lockfile.lock(itemPath, { retries: 5 });
if (!(await fs.pathExists(itemPath))) {
@@ -329,11 +420,16 @@ class Odmdb {
if (!validator.verify(finalItem)) {
return { status: 400, ref: "Odmdb", msg: "validation_failed", errors: validator.getErrors() };
}
const dirtyIndexes = this._getDirtyIndexes(existingItem, finalItem);
const cleanItem = validator.getCleanItem(finalItem);
await fs.outputJson(itemPath, cleanItem, { spaces: 2 });
await this._updateIndex();
if (dirtyIndexes.length === 1) {
await this.rebuildIndex(dirtyIndexes[0]);
} else if (dirtyIndexes.length > 1) {
await this._updateIndex();
}
const conf = await fs.pathExists(this.confPath) ? await fs.readJson(this.confPath) : {};
conf.lastupdatedata = dayjs().toISOString();
await fs.outputJson(this.confPath, conf);
@@ -407,6 +503,140 @@ class Odmdb {
}
}
/**
* Renames an item by changing its apxid key.
* The old item is archived to delitm/ with rename metadata.
* @param {string} oldKeyid - Current unique ID.
* @param {object|string} newItem - New item data (must contain the apxid field) or new key string.
* @param {object} role - The user's role.
* @returns {Promise<object>} A response object.
*/
async rename(oldKeyid, newItem, role) {
if (!(await this._loadSchema())) {
return { status: 500, ref: "Odmdb", msg: "schema_load_failed" };
}
const apxidKey = this.schema.apxid;
const newKeyid = (newItem && typeof newItem === 'object') ? (newItem[apxidKey] || newItem.newKeyid || newItem.keyid) : newItem;
if (!oldKeyid) {
return { status: 400, ref: "Odmdb", msg: "item_missing_apxid", data: { apxid_key: apxidKey } };
}
if (!newKeyid) {
return { status: 400, ref: "Odmdb", msg: "item_missing_apxid", data: { apxid_key: apxidKey } };
}
if (oldKeyid === newKeyid) {
return { status: 400, ref: "Odmdb", msg: "item_apxid_immutable", data: { keyid: oldKeyid, apxid_key: apxidKey } };
}
const itmDir = path.join(this.objPath, "itm");
const oldPath = path.join(itmDir, `${oldKeyid}.json`);
const newPath = path.join(itmDir, `${newKeyid}.json`);
await fs.ensureDir(itmDir);
// Ordered double-lock: old -> new (lexicographic order avoids deadlocks).
const lockOrder = [oldPath, newPath].sort();
const locked = [];
try {
for (const p of lockOrder) {
try {
await lockfile.lock(p, { retries: 5 });
locked.push(p);
} catch (lockErr) {
// New target usually does not exist yet: proper-lockfile requires
// an existing file. Proceed without that lock in that case.
const exists = await fs.pathExists(p);
if (exists) throw lockErr;
}
}
if (!(await fs.pathExists(oldPath))) {
return { status: 404, ref: "Odmdb", msg: "item_not_found_for_rename", data: { keyid: oldKeyid } };
}
if (await fs.pathExists(newPath)) {
return { status: 409, ref: "Odmdb", msg: "item_rename_conflict", data: { keyid: newKeyid, oldKeyid } };
}
const existingItem = await fs.readJson(oldPath);
const { hasPermission, allowedFields } = this._checkPermissions('U', role, existingItem);
if (!hasPermission) {
return { status: 403, ref: "Odmdb", msg: "permission_denied_U", data: { keyid: oldKeyid } };
}
const patch = (newItem && typeof newItem === 'object') ? newItem : {};
const processedPatch = await this._processImages({ ...patch, [apxidKey]: newKeyid });
const finalItem = { ...existingItem };
for (const key of allowedFields) {
if (key === apxidKey) continue;
if (processedPatch[key] !== undefined) {
finalItem[key] = processedPatch[key];
}
}
finalItem[apxidKey] = newKeyid;
finalItem.owner = existingItem.owner;
if (existingItem.dt_create) finalItem.dt_create = existingItem.dt_create;
finalItem.dt_update = dayjs().toISOString();
finalItem.dt_rename = dayjs().toISOString();
finalItem.renamed_from = oldKeyid;
// Rewrite image paths pointing at the old key.
for (const key of Object.keys(finalItem)) {
const v = finalItem[key];
if (typeof v === 'string' && v.includes(`${oldKeyid}_`)) {
finalItem[key] = v.split(`${oldKeyid}_`).join(`${newKeyid}_`);
}
}
const validator = new Checkjson(this.schema);
if (!validator.verify(finalItem)) {
return { status: 400, ref: "Odmdb", msg: "validation_failed", errors: validator.getErrors() };
}
const cleanItem = validator.getCleanItem(finalItem);
await fs.outputJson(newPath, cleanItem, { spaces: 2 });
const delDir = path.join(this.objPath, "delitm");
await fs.ensureDir(delDir);
const archived = { ...existingItem, dt_rename: dayjs().toISOString(), renamed_to: newKeyid };
await fs.outputJson(path.join(delDir, `${oldKeyid}.json`), archived);
await fs.remove(oldPath);
const imgDir = path.join(this.objPath, "img");
if (await fs.pathExists(imgDir)) {
const files = await fs.readdir(imgDir).catch(() => []);
for (const f of files) {
if (f.startsWith(`${oldKeyid}_`) && f.endsWith('.webp')) {
const from = path.join(imgDir, f);
const to = path.join(imgDir, `${newKeyid}_` + f.slice(oldKeyid.length + 1));
try {
await fs.move(from, to, { overwrite: false });
} catch (e) {
logger.warn(`Rename image move failed ${from} -> ${to}: ${e.message}`);
}
}
}
}
await this.rebuildIndex();
const conf = await fs.pathExists(this.confPath) ? await fs.readJson(this.confPath) : {};
conf.lastupdatedata = dayjs().toISOString();
await fs.outputJson(this.confPath, conf);
return { status: 200, ref: "Odmdb", msg: "item_renamed", data: { oldKeyid, keyid: newKeyid, objectname: this.objname, tribe: this.tribe } };
} catch (error) {
logger.error(`Error during rename ${oldKeyid} -> ${newKeyid}:`, error);
if (error && error.status === 404 && error.code === "index_not_found") {
return { status: 500, ref: "Odmdb", msg: "reindex_failed", error: error.message };
}
return { status: 500, ref: "Odmdb", msg: "rename_failed", error: error.message };
} finally {
for (const p of locked.reverse()) {
try {
if (await lockfile.check(p).catch(() => false)) {
await lockfile.unlock(p).catch(() => {});
}
} catch {}
}
}
}
/**
* Rebuilds all indexes based on the 'apxidx' definition in the schema.
*/
@@ -418,11 +648,27 @@ class Odmdb {
}
/**
* Rebuilds all indexes based on the 'apxidx' definition in the schema.
* Rebuilds indexes based on the 'apxidx' definition in the schema.
* When indexName is provided, only that index is rebuilt.
* @param {string} [indexName] - Optional single index name to rebuild.
* @returns {Promise<{rebuilt: string[], skipped: string[]}>} Rebuilt and skipped index names.
*/
async rebuildIndex() {
async rebuildIndex(indexName) {
if (!await this._loadSchema() || !this.schema.apxidx || this.schema.apxidx.length === 0) {
return;
return { rebuilt: [], skipped: [] };
}
let targets = this.schema.apxidx;
if (indexName) {
const found = this.schema.apxidx.find((i) => i.name === indexName);
if (!found) {
const err = new Error(`Index '${indexName}' not found in schema`);
err.status = 404;
err.code = "index_not_found";
err.data = { objectname: this.objname, index: indexName };
throw err;
}
targets = [found];
}
const itemDir = path.join(this.objPath, "itm");
@@ -430,10 +676,10 @@ class Odmdb {
const apxidKey = this.schema.apxid;
const indexConfigs = {};
for (const id of this.schema.apxidx) {
for (const id of targets) {
const keyval = id.keyval;
const isNested = keyval.includes(".");
if (!isNested && !this.schema.properties[keyval]) {
logger.warn(`For ${this.objname}, index '${id.name}' references non-existent property '${keyval}'. Skipping.`);
continue;
@@ -469,8 +715,7 @@ class Odmdb {
}
if (!keep) continue;
const getValue = (obj, path) => path.split('.').reduce((o, k) => (o && o[k] !== undefined) ? o[k] : null, obj);
const rawValue = getValue(itm, config.keyval);
const rawValue = this._getNested(itm, config.keyval);
if (rawValue === null || rawValue === undefined) continue;
const values = Array.isArray(rawValue) ? rawValue : [rawValue];
@@ -511,6 +756,7 @@ class Odmdb {
}
await fs.ensureDir(indexDir);
const rebuilt = [];
for (const name in indexConfigs) {
const config = indexConfigs[name];
const indexPath = path.join(indexDir, `${config.name}.json`);
@@ -519,9 +765,15 @@ class Odmdb {
if (dataToWrite instanceof Set) {
dataToWrite = Array.from(dataToWrite).sort();
}
await fs.outputJson(indexPath, dataToWrite, { spaces: 2 });
const tmpPath = `${indexPath}.tmp-${process.pid}-${Date.now()}`;
await fs.outputJson(tmpPath, dataToWrite, { spaces: 2 });
await fs.move(tmpPath, indexPath, { overwrite: true });
rebuilt.push(name);
}
const allNames = this.schema.apxidx.map((i) => i.name);
const skipped = allNames.filter((n) => !rebuilt.includes(n));
return { rebuilt, skipped };
}
/**
+218 -15
View File
@@ -4,6 +4,18 @@
* (models/mailconn.js sendMessage). Entries live in {DATAPATH}/{tribe}/objects/outbox/.
* Sending requires writer (mail-{mailboxid}-writer) or druid access on the mailbox.
* Status lifecycle: draft -> sending -> sent; failures -> error (message preserved).
*
* Reply threading: a draft carries the optional RFC 5322 In-Reply-To (string) and References
* (array of msg-ids) headers of the message it answers, using the same shapes as schema/mails.json.
* Both are normalised and validated by _threadHeaders on create/update, replayed to the transport
* on send, and copied to the mails document committed in the Sent folder.
*
* Bodies: `html` is an untrusted string. It is sanitised server-side by Mailcontent (allow-list
* of elements, attributes and inline CSS) on every write path, so a stored draft is already clean,
* and again on send, because drafts created before that sanitiser existed are still on disk and
* the transport is the real trust boundary. `text` is sanitised as a MIME part and a text/plain
* alternative is always guaranteed (Mailcontent.prepare), so an HTML-only body can never reach
* nodemailer as `text: ""`.
*/
const fs = require("fs-extra");
@@ -11,9 +23,16 @@ const path = require("path");
const dayjs = require("dayjs");
const Odmdb = require("./Odmdb.js");
const Mailboxes = require("./Mailboxes.js");
const Mailcontent = require("./Mailcontent.js");
const mailconn = require("./mailconn.js");
const DATAPATH = process.env.DATAPATH;
// Longest accepted msg-id. RFC 5322 caps a header line at 998 chars including the name and
// the angle brackets, so a single id can never legitimately be longer than that.
const MESSAGE_ID_MAX_LENGTH = 512;
// A References chain grows by one entry per hop and is then replayed on every send, so it is
// capped to the most recent entries (the ones mail clients actually thread on).
const REFERENCES_MAX = 20;
class Outbox {
/**
@@ -41,6 +60,93 @@ class Outbox {
.filter((a) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(a));
}
/**
* True when a value is a well formed RFC 5322 msg-id: angle brackets, exactly one "@",
* no whitespace, no nested angle bracket, quote or backslash. Obsolete quoted local parts
* are deliberately refused. Control characters are rejected explicitly: a CR or an LF
* here would end the header and let a caller append headers of its own to the message.
* @private
*/
static _isMessageId(value) {
if (typeof value !== "string" || !value || value.length > MESSAGE_ID_MAX_LENGTH) return false;
if (/[\x00-\x1f\x7f]/.test(value)) return false;
return /^<[^\s<>@"\\]+@[^\s<>@"\\]+>$/.test(value);
}
/**
* Truncate and strip control characters from a value rejected by _threadHeaders, so it can
* safely be echoed back in the error response (and logged) without replaying an injected
* line break.
* @private
*/
static _safeHeaderValue(value) {
return String(value === undefined || value === null ? "" : value)
.replace(/[\x00-\x1f\x7f]/g, " ")
.trim()
.slice(0, 120);
}
/**
* Normalise and validate the reply-threading headers of a draft. This is the single
* entry point used by createDraft, updateDraft, send and _commitSentMail, so a value
* reaching the transport is always a validated msg-id.
*
* - inReplyTo: a single msg-id. An array is tolerated, its last entry (the immediate
* parent) is taken. Empty means the draft starts a thread.
* - references: array, or string holding msg-ids separated by commas and/or whitespace.
* Entries are trimmed, de-duplicated (first occurrence kept) and stay in the order given,
* which must be chronological (RFC 5322 3.6.4).
* - the parent is appended to references when absent, so a draft always advertises its own
* ancestor chain even if the caller only sent inReplyTo.
* - the chain is capped to the REFERENCES_MAX most recent entries.
*
* @param {object} data - {inReplyTo, references}
* @returns {{ok:true, inReplyTo:string, references:string[]}|{ok:false, field:string, value:string}}
* ok:false identifies the offending field and carries a sanitised copy of the bad value.
* @private
*/
static _threadHeaders(data) {
const raw = data || {};
let inReplyTo = "";
if (Array.isArray(raw.inReplyTo)) {
inReplyTo = String(raw.inReplyTo.length ? raw.inReplyTo[raw.inReplyTo.length - 1] : "").trim();
} else if (raw.inReplyTo !== undefined && raw.inReplyTo !== null) {
inReplyTo = String(raw.inReplyTo).trim();
}
if (inReplyTo && !this._isMessageId(inReplyTo)) {
return { ok: false, field: "inReplyTo", value: this._safeHeaderValue(inReplyTo) };
}
const entries = Array.isArray(raw.references)
? raw.references
: String(raw.references === undefined || raw.references === null ? "" : raw.references).split(/[\s,]+/);
const references = [];
for (const entry of entries) {
const mid = String(entry === undefined || entry === null ? "" : entry).trim();
if (!mid) continue;
if (!this._isMessageId(mid)) {
return { ok: false, field: "references", value: this._safeHeaderValue(mid) };
}
if (!references.includes(mid)) references.push(mid);
}
if (inReplyTo && !references.includes(inReplyTo)) references.push(inReplyTo);
return { ok: true, inReplyTo, references: references.slice(-REFERENCES_MAX) };
}
/**
* The threading headers in the shape nodemailer expects: a single space separated
* In-Reply-To string and a single space separated References header value (RFC 5322 emits
* one References line, folded by the transport). Both are undefined when empty, so no empty
* header reaches the SMTP conversation.
* @private
*/
static _transportHeaders(threaded) {
if (!threaded || !threaded.ok) return { inReplyTo: undefined, references: undefined };
return {
inReplyTo: threaded.inReplyTo || undefined,
references: threaded.references.length ? threaded.references.join(" ") : undefined,
};
}
/**
* @private
*/
@@ -61,9 +167,12 @@ class Outbox {
/**
* Create a draft entry (writer/druid on the mailbox).
* The posted body is untrusted: `html` goes through the Mailcontent allow-list sanitiser and
* `text` is normalised, so what lands on disk is already safe to send. An HTML-only body gets a
* text/plain alternative derived from the sanitised HTML.
* @param {object} role - {xalias, xprofils}
* @param {string} tribe - tribe namespace of the outbox
* @param {object} data - {mailboxid,to,cc,bcc,subject,text,html}
* @param {object} data - {mailboxid,to,cc,bcc,subject,text,html,inReplyTo,references}
*/
static async createDraft(role, tribe, data) {
try {
@@ -82,10 +191,20 @@ class Outbox {
return { status: 400, ref: "Outbox", msg: "missing_fields", data: { fields: ["to"] } };
}
const subject = data.subject ? String(data.subject).slice(0, 300) : "(no subject)";
const tc = String(data.text || "");
const html = data.html ? String(data.html) : "";
const body = Mailcontent.prepare({ html: data.html, text: data.text });
const tc = body.text;
const html = body.html;
if (!tc && !html) {
return { status: 400, ref: "Outbox", msg: "missing_fields", data: { fields: ["text"] } };
// Distinguish "no body at all" from "a body the allow-list emptied": the caller gets a
// different error for each, since only the second one can be acted on.
const sentSomething = Mailcontent.hasContent(data.html) || Mailcontent.hasContent(data.text);
return sentSomething
? { status: 400, ref: "Outbox", msg: "body_empty_after_sanitize", data: { fields: ["html", "text"] } }
: { status: 400, ref: "Outbox", msg: "missing_fields", data: { fields: ["text"] } };
}
const threaded = this._threadHeaders(data);
if (!threaded.ok) {
return { status: 400, ref: "Outbox", msg: "invalid_headers", data: { field: threaded.field, value: threaded.value } };
}
const base = Mailboxes._slug(`${subject.slice(0, 40)}-${Date.now()}`);
const outboxId = base || `out-${Date.now().toString(36)}`;
@@ -100,6 +219,8 @@ class Outbox {
subject,
text: tc,
html,
inReplyTo: threaded.inReplyTo,
references: threaded.references,
attachments: [],
status: "draft",
messageId: "",
@@ -144,6 +265,24 @@ class Outbox {
if ("subject" in patch) next.subject = String(patch.subject).slice(0, 300);
if ("text" in patch) next.text = String(patch.text || "");
if ("html" in patch) next.html = String(patch.html || "");
// Sanitised unconditionally, not only when the patch carries a body: the stored value may
// be a draft written before the sanitiser existed or edited by another client, and it is
// the value that will be sent. Preparing (rather than just sanitising) also guarantees the
// text/plain alternative on a patch that replaces the body with HTML only.
const body = Mailcontent.prepare({ html: next.html, text: next.text });
next.html = body.html;
next.text = body.text;
// Either header can be edited on its own; the untouched one is re-read from the entry so
// the parent stays appended to the chain when only inReplyTo is changed (and conversely).
const threaded = this._threadHeaders({
inReplyTo: "inReplyTo" in patch ? patch.inReplyTo : entry.inReplyTo,
references: "references" in patch ? patch.references : entry.references,
});
if (!threaded.ok) {
return { status: 400, ref: "Outbox", msg: "invalid_headers", data: { field: threaded.field, value: threaded.value } };
}
next.inReplyTo = threaded.inReplyTo;
next.references = threaded.references;
if (next.to.length === 0) {
return { status: 400, ref: "Outbox", msg: "missing_fields", data: { fields: ["to"] } };
}
@@ -162,6 +301,10 @@ class Outbox {
* attached tribe: {DATAPATH}/{tribe}/objects/mails/. emailId is derived from the
* Message-ID (mailconn.emailIdForSent) so a later IMAP Sent-folder sync upserts the very
* same document instead of duplicating it. The rendered body is cached on disk.
* The committed document carries the Message-ID actually sent plus the In-Reply-To and
* References headers of the reply, so a sent answer threads in the Sent folder and in mymail.
* Threading headers are re-validated here (and fall back to empty when they are unusable),
* because this method can also be reached from a draft written before threading existed.
* Returns the emailId used, or "" when the message carried no Message-ID.
* @private
*/
@@ -171,8 +314,15 @@ class Outbox {
const fromEmail = (mailbox.connector && mailbox.connector.user) || mailbox.email || "";
const toArr = this._addresses(entry.to).map((email) => ({ name: "", email }));
const ccArr = this._addresses(entry.cc).map((email) => ({ name: "", email }));
const text = String(entry.text || "");
const bodyHtml = entry.html || (text ? `<pre>${text}</pre>` : "");
const text = String(entry.text || "");
// entry.html is already sanitised (send() stores what it sent). The plain-text fallback is
// escaped: text is not markup, and interpolating it raw would let "1 < 2" open a tag in the
// body that the PWA re-renders.
const bodyHtml = entry.html || (text ? `<pre>${Mailcontent.escapeHtml(text)}</pre>` : "");
const threaded = this._threadHeaders(entry);
if (!threaded.ok) {
console.error(`[Outbox] invalid threading headers on ${entry.outboxId}: ${threaded.field} = "${threaded.value}"`);
}
const item = {
emailId,
provider: mailbox.provider || "imap",
@@ -183,8 +333,8 @@ class Outbox {
mailboxid: mailbox.mailboxid,
tribe: mailbox.tribe,
threadId: "",
inReplyTo: entry.inReplyTo || "",
references: Array.isArray(entry.references) ? entry.references : [],
inReplyTo: threaded.ok ? threaded.inReplyTo : "",
references: threaded.ok ? threaded.references : [],
subject: entry.subject || "(no subject)",
snippet: text ? text.replace(/\s+/g, " ").slice(0, 200) : "",
from: { email: fromEmail, name: mailbox.label || "" },
@@ -222,6 +372,9 @@ class Outbox {
/**
* Send a draft (or resend an errored entry). Requires writer/druid on the mailbox.
* Delivers through mailconn.sendMessage using the mailbox SMTP configuration.
* Replays the draft In-Reply-To / References headers (validated again at the transport
* boundary) and commits the sent message, Message-ID and threading headers included, as an
* apxtriSent mail of the Sent folder so the answer threads with the message it answers.
* @param {object} role
* @param {string} tribe
* @param {string} outboxId
@@ -240,9 +393,43 @@ class Outbox {
if (!(await this._canUse(role, tribe, mailbox))) {
return { status: 403, ref: "Outbox", msg: "right_required", data: { right: "write", mailboxid: entry.mailboxid } };
}
// Validated here, at the boundary with the transport, so the draft (in every status) and
// the committed mail carry the exact headers that were sent. A draft with unusable headers
// (hand edited file, entry predating reply threading) still sends, unthreaded.
const threaded = this._threadHeaders(entry);
if (!threaded.ok) {
console.error(`[Outbox] invalid threading headers on ${outboxId}: ${threaded.field} = "${threaded.value}"`);
}
const safe = threaded.ok ? threaded : { inReplyTo: "", references: [] };
const transportHeaders = this._transportHeaders(safe);
// Sanitised again here, and not redundantly. createDraft and updateDraft already clean the
// body, but (a) drafts written before that sanitiser existed are still on disk and are
// loaded straight from JSON, (b) a file on disk is not a trusted channel either, and
// (c) this is the last point before the SMTP conversation, so it is the right place for the
// boundary to be. The sanitiser is idempotent, so re-running it over an already clean body
// is a no-op. The clean values are written into `marking`, which means the persisted entry
// is byte-for-byte what was sent and satisfies the schema constraints on the bodies.
const body = Mailcontent.prepare({ html: entry.html, text: entry.text });
if (!body.text && !body.html) {
return { status: 400, ref: "Outbox", msg: "body_empty_after_sanitize", data: { fields: ["html", "text"] } };
}
const outboxDb = new Odmdb("outbox", tribe);
const marking = { ...entry, status: "sending", error: "" };
await outboxDb.update(marking, this._druidRole(role));
const marking = {
...entry,
status: "sending",
error: "",
html: body.html,
text: body.text,
inReplyTo: safe.inReplyTo,
references: safe.references,
};
// The marking update is now checked (it used to be ignored): the message must not leave
// without its "sending" state recorded, otherwise a later failure leaves a sent draft in
// "draft" and the next click sends it a second time.
const markingResult = await outboxDb.update(marking, this._druidRole(role));
if (markingResult.status !== 200) {
return { status: markingResult.status, ref: "Outbox", msg: "send_failed", data: { outboxId, error: "outbox entry could not be marked as sending" } };
}
try {
const secrets = await Mailboxes._loadSecrets(mailbox);
const sent = await mailconn.sendMessage(mailbox, secrets, {
@@ -250,19 +437,35 @@ class Outbox {
cc: entry.cc,
bcc: entry.bcc,
subject: entry.subject,
text: entry.text,
html: entry.html,
text: body.text,
html: body.html,
inReplyTo: transportHeaders.inReplyTo,
references: transportHeaders.references,
});
if (!sent.ok) {
await outboxDb.update({ ...marking, status: "error", error: String(sent.error || "").slice(0, 400) }, this._druidRole(role));
return { status: 502, ref: "Outbox", msg: "send_failed", data: { outboxId, error: sent.error } };
}
const done = { ...marking, status: "sent", sentAt: dayjs().toISOString(), error: "", messageId: sent.response || "" };
await outboxDb.update(done, this._druidRole(role));
const done = {
...marking,
status: "sent",
sentAt: dayjs().toISOString(),
error: "",
messageId: sent.response || "",
inReplyTo: safe.inReplyTo,
references: safe.references,
};
// The message is on its way: a failure to record it must not be reported as a send
// failure (the caller would offer a retry that would duplicate the mail). Logged only.
const doneResult = await outboxDb.update(done, this._druidRole(role));
if (doneResult.status !== 200) {
console.error(`[Outbox] could not record sent state of ${outboxId}:`, JSON.stringify(doneResult.errors || doneResult.msg));
}
let emailId = "";
if (done.messageId) {
try {
emailId = await this._commitSentMail(mailbox, entry, done.messageId, done.sentAt);
// `done` (not `entry`): commit the exact entry that was sent, threading headers included.
emailId = await this._commitSentMail(mailbox, done, done.messageId, done.sentAt);
} catch (e) {
console.error("[Outbox] commit sent mail failed:", e.message);
}
+4 -4
View File
@@ -531,10 +531,10 @@ class Pagans {
static _personFieldAccess(viewer, person, acl) {
const schemaRights = { owner: {}, druids: {}, pagans: {}, mayors: {} };
// Mirrors persons.json apxaccessrights (R/U field lists).
schemaRights.owner.R = ["alias", "owner", "profils", "dt_create", "dt_update", "dt_lastlogin", "dt_birth", "emailcom", "hobbies", "biography", "imgavatar"];
schemaRights.owner.U = ["dt_birth", "emailcom", "hobbies", "biography", "imgavatar", "imgbase64_imgavatar", "profils"];
schemaRights.druids.R = ["alias", "owner", "profils", "dt_create", "dt_update", "dt_lastlogin", "dt_birth", "emailcom", "hobbies", "biography", "imgavatar", "apxgain", "druidsinfo"];
schemaRights.druids.U = ["apxgain", "profils", "druidsinfo", "imgavatar", "imgbase64_imgavatar"];
schemaRights.owner.R = ["alias", "owner", "profils", "dt_create", "dt_update", "dt_lastlogin", "dt_birth", "emailcom", "hobbies", "biography", "imgavatar", "nom", "prenom", "telephone", "genre", "titre", "mobile", "fixe"];
schemaRights.owner.U = ["dt_birth", "emailcom", "hobbies", "biography", "imgavatar", "imgbase64_imgavatar", "profils", "nom", "prenom", "telephone", "genre", "titre", "mobile", "fixe"];
schemaRights.druids.R = ["alias", "owner", "profils", "dt_create", "dt_update", "dt_lastlogin", "dt_birth", "emailcom", "hobbies", "biography", "imgavatar", "apxgain", "druidsinfo", "genre", "titre", "mobile", "fixe"];
schemaRights.druids.U = ["apxgain", "profils", "druidsinfo", "imgavatar", "imgbase64_imgavatar", "nom", "prenom", "telephone", "genre", "titre", "mobile", "fixe", "emailcom", "dt_birth"];
schemaRights.pagans.R = [];
schemaRights.mayors.R = ["alias", "apxgain"];
+1073
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -138,6 +138,18 @@ module.exports = router;
await fs.writeFile(path.join(tribeCodePath, '.gitignore'), 'node_modules\n.env\n', 'utf8');
if (log) console.log(`[Tribes] package.json + .gitignore prepared for ${tribeId}`);
// 4e. Email templates directory (versioned in the tribe repository).
// The tribe owns {NODEPATH}/{tribe}/template/, resolved first by
// Templates.resolvePath. Seed the generic templates when missing
// (idempotent, never overwrite a customized template).
try {
const seed = require('./Templates.js');
const seedResult = await seed.seed(tribeId);
if (log) console.log(`[Tribes] template seed for ${tribeId}:`, seedResult.msg, seedResult.data);
} catch (err) {
console.error(`[Tribes] Failed to seed templates for ${tribeId}:`, err.message);
}
// 5. The new tribe owns its profil_* options from day one. They are
// seeded from the town's copies into BOTH the tribe runtime namespace
// ({DATAPATH}/{tribe}/objects/options/itm/) and the tribe repository
+10 -36
View File
@@ -436,7 +436,6 @@ class Wwws {
}
static async createSubapp(tribe, app, subapp) {
const np = process.env.NODEPATH;
const dp = process.env.DATAPATH;
if (!/^[a-z0-9-]+$/.test(subapp)) {
return { status: 400, ref: "Wwws", msg: "invalid_subapp_name", data: { name: subapp } };
@@ -482,28 +481,6 @@ class Wwws {
}
}
// Create draft symlink for immediate dev access without server restart
const draftPath = path.join(dp, tribe, 'wwws', app, subapp, 'draft');
try {
// lstat does not follow the target: detects broken/stale symlinks too
let st = null;
try { st = await fs.lstat(draftPath); } catch { /* not found */ }
if (st) {
if (st.isSymbolicLink()) {
const existing = await fs.readlink(draftPath);
if (existing === srcPath) { /* already correct */ }
else { await fs.unlink(draftPath); }
} else {
await fs.remove(draftPath);
}
}
await fs.ensureDir(path.dirname(draftPath));
await fs.symlink(srcPath, draftPath);
logger.info(`Draft symlink: ${draftPath} → ${srcPath}`);
} catch (err) {
logger.warn(`Failed to create draft symlink for ${subapp}: ${err.message}`);
}
return {
status: 201,
ref: "Wwws",
@@ -531,7 +508,7 @@ class Wwws {
const updated = [];
const entries = await fs.readdir(appDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === 'draft') continue;
if (!entry.isDirectory()) continue;
const subappCssPath = path.join(appDir, entry.name, 'css', 'style.css');
if (await fs.pathExists(path.dirname(subappCssPath))) {
if (await fs.pathExists(subappCssPath)) {
@@ -735,14 +712,13 @@ class Wwws {
* no `group:apxtri` entry on the tree, apxtri falls back to `other::r-x` (read
* only) and the build dies with a bare EACCES on an `unlink`, halfway through the
* copy, leaving a half-provisioned dist.
* - read: the source is served live through the `draft/` symlinks
* ({DATAPATH}/.../draft -> {NODEPATH}/...), so a source file the runtime user
* cannot read breaks the app at request time with no build error at all.
* - read: the build reads the sources under {NODEPATH}, so a source file the
* runtime user cannot read breaks the build with no self-explanatory error.
*
* mode 'w' tests W_OK|X_OK on directories (what copy/symlink/unlink need);
* mode 'r' tests R_OK|X_OK on directories and R_OK on files (what serving needs).
* Symlinks are skipped: the target's permissions govern, and following `css/` or
* `draft/` would walk out of the tree being checked.
* mode 'r' tests R_OK|X_OK on directories and R_OK on files (what the build reads need).
* Symlinks are skipped: the target's permissions govern, and following `css/`
* would walk out of the tree being checked.
*/
static async _checkTreeAccess(root, mode) {
const failures = [];
@@ -841,8 +817,8 @@ class Wwws {
};
}
// 2. Read: the source is served live through draft/ symlinks, so unreadable files
// break the app at request time with no build error at all.
// 2. Read: the build reads the sources, so unreadable files break the build
// with no self-explanatory error.
const readable = await Wwws._checkTreeAccess(srcBase, 'r');
if (!readable.ok) {
return {
@@ -983,7 +959,6 @@ class Wwws {
const entries = await fs.readdir(srcBase, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
if (entry.name === 'draft') continue;
if (subappName && entry.name !== subappName) continue;
const subSrc = path.join(srcBase, entry.name);
if (await fs.pathExists(path.join(subSrc, 'manifest.json'))) {
@@ -1023,7 +998,7 @@ class Wwws {
// Provision the served CDN for this tribe (see _provisionTribeCdn). A tribe
// deployed via git (update.sh / mytribes "Pull & deploy") never went through
// init.sh, which only provisions the TOWN cdn — without this every
// /cdn/{tribe}/lib/* (axios, draft, auth, localdb, menu…) 404's.
// /cdn/{tribe}/lib/* (axios, auth, localdb, menu…) 404's.
await this._provisionTribeCdn(tribeId);
const twCli = path.join(np, 'apxtri', 'node_modules', '@tailwindcss', 'cli', 'dist', 'index.mjs');
@@ -1097,7 +1072,7 @@ class Wwws {
await fs.remove(legDist);
}
}
// Legacy: copy output.css back to source so draft mode serves it
// Legacy: copy output.css back to source so the source tree stays in sync
if (copyBackSrc) {
try {
await fs.copyFile(cssDist, copyBackSrc);
@@ -1271,7 +1246,6 @@ class Wwws {
let subs;
try { subs = await fs.readdir(appDir); } catch { continue; }
for (const sub of subs.sort()) {
if (sub === 'draft') continue;
const subDir = path.join(appDir, sub);
try { st = await fs.stat(subDir); } catch { continue; }
if (!st.isDirectory()) continue;
+28 -4
View File
@@ -14,6 +14,7 @@ const POP3Client = require("poplib");
const nodemailer = require("nodemailer");
const crypto = require("crypto");
const dayjs = require("dayjs");
const Mailcontent = require("./Mailcontent.js");
const PROVIDERS = {
imap: { authType: "password", label: "IMAP", needsConnector: true, needsSecrets: true, defaultConnector: { port: 993, tls: true, folder: "INBOX" } },
@@ -907,10 +908,16 @@ function smtpConfig(mailbox, secrets) {
const config = {
host: conn.host,
port: parseInt(conn.port, 10) || 465,
secure: conn.tls !== false && conn.tls !== 0,
secure: (parseInt(conn.port, 10) === 465) ? true : (conn.tls !== false && conn.tls !== 0),
};
if (!config.host) return null;
config.auth = { user: mailFrom, pass: (secrets && secrets.password) || "" };
if (config.port === 587 && config.secure === true) {
config.secure = false;
}
config.auth = { user: mailFrom, pass: (secrets && (secrets.smtpPassword || secrets.password)) || "" };
if (config.secure === false && config.port === 587) {
config.requireTLS = true;
}
return config;
}
@@ -928,11 +935,28 @@ async function smtpVerify(mailbox, secrets) {
}
}
/**
* Send a message through the mailbox SMTP configuration.
*
* The bodies are normalised one last time here (models/Mailcontent.js): `html` is reduced to the
* allow-listed fragment and `text` is sanitised as a MIME part. This is the last point before the
* SMTP conversation, so it is the guarantee that `text: ""` can never be handed to nodemailer on
* an HTML-only send (a text/plain alternative is derived from the sanitised HTML) and that no
* caller can reach the transport with an unsanitised body. Callers normally sanitise earlier too
* (Outbox.createDraft / Outbox.send); doing it here as well is idempotent and not redundant, it
* is simply the boundary that must hold for every caller, present and future.
*
* @param {object} mailbox - mailbox document ({provider, connector, email})
* @param {object} secrets - {password}
* @param {object} msg - {to, cc, bcc, subject, text, html, messageId, inReplyTo, references}
* @returns {Promise<{ok:boolean, error:?string, response:?string}>} response carries the Message-ID.
*/
async function sendMessage(mailbox, secrets, msg) {
const config = smtpConfig(mailbox, secrets);
if (!config) {
return { ok: false, error: "no SMTP configuration for this mailbox", response: null };
}
const body = Mailcontent.prepare({ html: msg.html, text: msg.text });
const transport = nodemailer.createTransport(config);
try {
const info = await transport.sendMail({
@@ -941,8 +965,8 @@ async function sendMessage(mailbox, secrets, msg) {
cc: msg.cc || [],
bcc: msg.bcc || [],
subject: msg.subject || "",
text: msg.text || "",
html: msg.html || undefined,
text: body.text,
html: body.html || undefined,
messageId: msg.messageId || undefined,
inReplyTo: msg.inReplyTo || undefined,
references: msg.references || undefined,
+18
View File
@@ -0,0 +1,18 @@
{
"contact_created": "Contact {{data.uid}} created.",
"contact_fetched": "Contact {{data.uid}} retrieved.",
"contact_listed": "Contacts listed ({{data.total}}).",
"contact_found": "Contact found.",
"contact_updated": "Contact {{data.uid}} updated.",
"contact_deleted": "Contact {{data.uid}} deleted.",
"contact_not_found": "Contact {{data.uid}} not found.",
"contact_invalid": "Invalid contact data: {{data.error}}.",
"tribe_not_found": "Tribe {{data.tribe}} does not exist.",
"druid_only": "This action is reserved to druids of tribe {{data.tribe}}.",
"duplicates_found": "{{data.total}} duplicate pair(s) found.",
"contacts_merged": "Contacts merged into {{data.uid}} (dropped {{data.dropped}}).",
"cannot_merge_same": "Cannot merge a contact with itself ({{data.uid}}).",
"duplicate_dismissed": "Duplicate pair {{data.pairKey}} dismissed.",
"relation_unknown": "Unknown relation target {{data.uid}}: {{data.detail}}.",
"relation_invalid": "Invalid relation: {{data.detail}}."
}
+18
View File
@@ -0,0 +1,18 @@
{
"contact_created": "Contact {{data.uid}} créé.",
"contact_fetched": "Contact {{data.uid}} récupéré.",
"contact_listed": "Contacts listés ({{data.total}}).",
"contact_found": "Contact trouvé.",
"contact_updated": "Contact {{data.uid}} mis à jour.",
"contact_deleted": "Contact {{data.uid}} supprimé.",
"contact_not_found": "Contact {{data.uid}} introuvable.",
"contact_invalid": "Données de contact invalides : {{data.error}}.",
"tribe_not_found": "La tribu {{data.tribe}} n'existe pas.",
"druid_only": "Cette action est réservée aux druides de la tribu {{data.tribe}}.",
"duplicates_found": "{{data.total}} paire(s) de doublons trouvée(s).",
"contacts_merged": "Contacts fusionnés dans {{data.uid}} (abandonné {{data.dropped}}).",
"cannot_merge_same": "Impossible de fusionner un contact avec lui-même ({{data.uid}}).",
"duplicate_dismissed": "Paire de doublons {{data.pairKey}} écartée.",
"relation_unknown": "Cible de relation inconnue {{data.uid}} : {{data.detail}}.",
"relation_invalid": "Relation invalide : {{data.detail}}."
}
+5 -1
View File
@@ -38,5 +38,9 @@
"attachment_found": "Attachment ready.",
"attachment_fetched": "Attachment fetched from provider and cached.",
"attachment_not_found": "Attachment {{data.index}} not found on mail {{data.emailid}}.",
"attachment_unavailable": "Attachment file is not available: {{error}}"
"attachment_unavailable": "Attachment file is not available: {{error}}",
"attachment_invalid": "Invalid attachment payload for mail {{data.emailid}} (filename, mimeType and a non-empty buffer are required).",
"attachment_too_large": "Attachment of {{data.size}} bytes is too large for mail {{data.emailid}} (maximum {{data.max}} bytes).",
"attachment_exists": "Attachment {{data.filename}} is already attached at index {{data.index}} of mail {{data.emailId}}.",
"attachment_added": "Attachment {{data.filename}} ({{data.contentType}}, {{data.size}} bytes) added to mail {{data.emailId}}."
}

Some files were not shown because too many files have changed in this diff Show More