Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e94e440858 |
@@ -25,7 +25,6 @@ jobs:
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
target-branch: stable
|
||||
|
||||
# Auto-approve + auto-merge the open stable release PR. See the beta
|
||||
# workflow for the full rationale. Skipped on the release-cutting run and
|
||||
|
||||
@@ -17,9 +17,9 @@ name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, beta, stable]
|
||||
branches: [main, beta]
|
||||
pull_request:
|
||||
branches: [main, beta, stable]
|
||||
branches: [main, beta]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.83.0-beta.0"
|
||||
".": "3.82.4-beta.0"
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
{".":"3.45.2"}
|
||||
{
|
||||
".": "2.6.1"
|
||||
}
|
||||
|
||||
+1002
-811
File diff suppressed because it is too large
Load Diff
+1
-8
@@ -27,15 +27,8 @@ FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Redeclare CACHEBUST — ARGs don't cross stage boundaries, so the builder
|
||||
# stage's declaration never reached this stage. Consuming it in the RUN below
|
||||
# busts that layer's cache every CI run (CACHEBUST=github.run_number), so the
|
||||
# image always picks up current Alpine security updates instead of reusing a
|
||||
# stale cached upgrade layer.
|
||||
ARG CACHEBUST=1
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Upgrade the npm CLI in the final image so its bundled deps are patched
|
||||
# (sigstore 4.x, tar) — closes CVE-2026-48815 and the older @sigstore/core / tar
|
||||
|
||||
@@ -239,7 +239,7 @@ describe('workflow engine', () => {
|
||||
expect(again.already).toBe(true);
|
||||
});
|
||||
|
||||
test('seeds the invoice-dunning built-in as the delegation graph (disabled for first beta)', async () => {
|
||||
test('seeds the invoice-dunning built-in as the delegation graph (v6, disabled for first beta)', async () => {
|
||||
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
|
||||
const noopLogger = { info() {}, warn() {} };
|
||||
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
|
||||
@@ -248,7 +248,7 @@ describe('workflow engine', () => {
|
||||
expect(wf).toBeTruthy();
|
||||
expect(!!wf.is_builtin).toBe(true);
|
||||
expect(!!wf.enabled).toBe(false); // first beta: ships disabled; legacy ladder runs until enabled
|
||||
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(7);
|
||||
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(6);
|
||||
|
||||
const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version });
|
||||
expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1);
|
||||
@@ -273,7 +273,7 @@ describe('workflow engine', () => {
|
||||
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
|
||||
const reseeded = await db('workflows').where({ id: wf.id }).first();
|
||||
expect(reseeded.version).toBe(wf.version + 1); // bumped
|
||||
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(7);
|
||||
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(6);
|
||||
expect(!!reseeded.enabled).toBe(false); // seed default re-applied (not admin-owned → flips enabled→disabled)
|
||||
const newNodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: reseeded.version });
|
||||
expect(newNodes.some((n) => n.type === 'gate')).toBe(false); // legacy graph replaced
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* Regression test for GHSA-9hmx-68vc-qpqw — share-link login must not bypass
|
||||
* the gallery password.
|
||||
*
|
||||
* POST /auth/gallery/share-login validates only the share token. For a
|
||||
* password-protected gallery it previously minted a full `type:'gallery'`
|
||||
* access token on the share token alone, letting anyone holding the share URL
|
||||
* read the gallery without the password. The fix: when the gallery requires a
|
||||
* password, return `{ requires_password: true }` with NO token and NO cookie.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
process.env.JWT_SECRET = 'share-login-test-secret';
|
||||
|
||||
const events = [];
|
||||
|
||||
jest.mock('../../src/database/db', () => {
|
||||
function dbFn(table) {
|
||||
if (table === 'events') {
|
||||
let filter = () => true;
|
||||
return {
|
||||
where(criteria) {
|
||||
filter = (row) => Object.entries(criteria).every(([k, v]) => {
|
||||
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
|
||||
if (k === 'is_archived') return Boolean(row.is_archived) === Boolean(v);
|
||||
return row[k] === v;
|
||||
});
|
||||
return this;
|
||||
},
|
||||
async first() { return events.find(filter); },
|
||||
};
|
||||
}
|
||||
return { where() { return this; }, async first() { return undefined; } };
|
||||
}
|
||||
dbFn.raw = async () => {};
|
||||
return { db: dbFn, logActivity: async () => {} };
|
||||
});
|
||||
|
||||
// Share token is stored plainly on the fake event row.
|
||||
jest.mock('../../src/services/shareLinkService', () => ({
|
||||
getEventShareToken: (event) => event.share_token,
|
||||
resolveShareIdentifier: async () => ({ event: null }),
|
||||
}));
|
||||
|
||||
const mockSetGalleryAuthCookies = jest.fn();
|
||||
jest.mock('../../src/utils/tokenUtils', () => ({
|
||||
setGalleryAuthCookies: (...args) => mockSetGalleryAuthCookies(...args),
|
||||
clearGalleryAuthCookies: jest.fn(),
|
||||
getGalleryTokenFromRequest: jest.fn(),
|
||||
setAdminAuthCookies: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/authSecurity', () => ({
|
||||
trackFailedAttempt: jest.fn(async () => {}),
|
||||
trackSuccessfulLogin: jest.fn(async () => {}),
|
||||
checkAccountLockout: jest.fn(async () => ({ isLocked: false })),
|
||||
resetLockout: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
// Collaborators the router imports at load but the share-login path doesn't hit.
|
||||
jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: async () => true }));
|
||||
jest.mock('../../src/services/mfaService', () => ({}));
|
||||
jest.mock('../../src/middleware/sessionTimeout', () => ({ endSession: jest.fn(), sessionTimeoutMiddleware: (req, res, next) => next() }));
|
||||
jest.mock('../../src/utils/tokenRevocation', () => ({ revokeToken: jest.fn(async () => {}), isTokenRevoked: async () => false }));
|
||||
|
||||
const authRouter = require('../../src/routes/auth');
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/auth', authRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
const SHARE_TOKEN = 'a'.repeat(64);
|
||||
|
||||
beforeEach(() => {
|
||||
events.length = 0;
|
||||
mockSetGalleryAuthCookies.mockClear();
|
||||
});
|
||||
|
||||
describe('POST /auth/gallery/share-login password enforcement', () => {
|
||||
it('does NOT mint a token for a password-protected gallery', async () => {
|
||||
events.push({
|
||||
id: 1, slug: 'private-gallery', is_active: 1, is_archived: 0,
|
||||
require_password: 1, share_token: SHARE_TOKEN, event_name: 'Private',
|
||||
});
|
||||
const res = await request(makeApp())
|
||||
.post('/auth/gallery/share-login')
|
||||
.send({ slug: 'private-gallery', token: SHARE_TOKEN });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.requires_password).toBe(true);
|
||||
expect(res.body.token).toBeUndefined();
|
||||
expect(mockSetGalleryAuthCookies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mints a token for a public (no-password) gallery', async () => {
|
||||
events.push({
|
||||
id: 2, slug: 'public-gallery', is_active: 1, is_archived: 0,
|
||||
require_password: false, share_token: SHARE_TOKEN, event_name: 'Public',
|
||||
});
|
||||
const res = await request(makeApp())
|
||||
.post('/auth/gallery/share-login')
|
||||
.send({ slug: 'public-gallery', token: SHARE_TOKEN });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
expect(res.body.event).toBeDefined();
|
||||
expect(mockSetGalleryAuthCookies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects a wrong share token regardless of password setting', async () => {
|
||||
events.push({
|
||||
id: 3, slug: 'public-gallery', is_active: 1, is_archived: 0,
|
||||
require_password: false, share_token: SHARE_TOKEN, event_name: 'Public',
|
||||
});
|
||||
const res = await request(makeApp())
|
||||
.post('/auth/gallery/share-login')
|
||||
.send({ slug: 'public-gallery', token: 'b'.repeat(64) });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(mockSetGalleryAuthCookies).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* Regression test for GHSA-4j34-x562-5vfq — broken access control in the legacy
|
||||
* /api/events router.
|
||||
*
|
||||
* The legacy router exposed create/list/update/delete/extend guarded by
|
||||
* adminAuth ALONE (no requirePermission, no requireEventOwnership), so any
|
||||
* back-office account — down to a read-only viewer — could read every gallery's
|
||||
* password_hash/share_token and take over any gallery. The fix removes that
|
||||
* router entirely and migrates its one UI-used route (POST /:id/extend) to the
|
||||
* canonical /api/admin/events mount, where it inherits the permission +
|
||||
* ownership guards.
|
||||
*
|
||||
* This test pins two invariants:
|
||||
* 1. The legacy source file is gone (nothing can re-mount it).
|
||||
* 2. The migrated extend route enforces ownership — a non-owning editor gets
|
||||
* 403, the owner succeeds.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-legacy-acl-')), 'db.sqlite'
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'legacy-acl-test-secret';
|
||||
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
|
||||
async function insertEvent(db, ownerId, over = {}) {
|
||||
const base = {
|
||||
slug: `ev-${Math.random().toString(16).slice(2)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Owner Gallery',
|
||||
event_date: '2026-05-29',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
|
||||
share_token: `st-${Math.random().toString(16).slice(2)}`,
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_by: ownerId,
|
||||
created_at: new Date().toISOString(),
|
||||
...over,
|
||||
};
|
||||
const r = await db('events').insert(base).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
}
|
||||
|
||||
describe('GHSA-4j34: legacy /api/events router removed + extend guarded', () => {
|
||||
it('the legacy events router source file no longer exists', () => {
|
||||
expect(fs.existsSync(path.join(__dirname, '../../src/routes/events.js'))).toBe(false);
|
||||
});
|
||||
|
||||
describe('POST /api/admin/events/:id/extend ownership enforcement', () => {
|
||||
let db; let cleanup; let app;
|
||||
let ownerId; let ownerToken;
|
||||
let editorId; let editorToken;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId: ownerId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, ownerId, 'super_admin');
|
||||
ownerToken = mintAdminToken(ownerId);
|
||||
|
||||
// A second, non-owning account with the low-trust editor role.
|
||||
[editorId] = await db('admin_users').insert({
|
||||
username: 'editor1', email: 'editor1@example.com',
|
||||
password_hash: 'x', is_active: 1,
|
||||
}).returning('id');
|
||||
editorId = editorId?.id ?? editorId;
|
||||
await assignAdminRole(db, editorId, 'editor');
|
||||
editorToken = mintAdminToken(editorId);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
|
||||
});
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
it('lets the owner extend their own gallery', async () => {
|
||||
const id = await insertEvent(db, ownerId, { expires_at: '2026-06-01T00:00:00.000Z' });
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/events/${id}/extend`)
|
||||
.set('Authorization', `Bearer ${ownerToken}`)
|
||||
.send({ days: 10 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(new Date(res.body.expires_at).toISOString()).toBe('2026-06-11T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('403s a non-owning editor trying to extend someone else\'s gallery', async () => {
|
||||
const id = await insertEvent(db, ownerId); // owned by the super_admin
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/events/${id}/extend`)
|
||||
.set('Authorization', `Bearer ${editorToken}`)
|
||||
.send({ days: 30 });
|
||||
expect(res.status).toBe(403); // requireEventOwnership blocks it
|
||||
});
|
||||
|
||||
it('validates the days field', async () => {
|
||||
const id = await insertEvent(db, ownerId);
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/events/${id}/extend`)
|
||||
.set('Authorization', `Bearer ${ownerToken}`)
|
||||
.send({ days: 9999 });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
// Point storage at a throwaway temp dir before requiring the service so the
|
||||
// module-level getStoragePath() picks it up if evaluated.
|
||||
process.env.STORAGE_PATH = path.join(os.tmpdir(), `picpeak-chunk-test-${process.pid}`);
|
||||
|
||||
const chunkedUpload = require('../../src/services/chunkedUploadService');
|
||||
|
||||
describe('chunkedUploadService.initializeUpload filename sanitisation (GHSA-pc72-jf53-w28j)', () => {
|
||||
afterAll(async () => {
|
||||
await fs.rm(process.env.STORAGE_PATH, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it('strips directory-traversal components from the stored filename', async () => {
|
||||
const { uploadId } = await chunkedUpload.initializeUpload({
|
||||
filename: '../../uploads/logos/evil.svg',
|
||||
fileSize: 10,
|
||||
mimeType: 'video/mp4',
|
||||
eventId: 1,
|
||||
totalChunks: 1,
|
||||
});
|
||||
const meta = chunkedUpload.getUploadStatus(uploadId);
|
||||
// basename('../../uploads/logos/evil.svg') === 'evil.svg' — the traversal
|
||||
// is gone, so path.join(tempDir, filename) can no longer escape tempDir.
|
||||
expect(meta.filename).toBe('evil.svg');
|
||||
});
|
||||
|
||||
it('keeps a normal filename intact', async () => {
|
||||
const { uploadId } = await chunkedUpload.initializeUpload({
|
||||
filename: 'clip.mp4',
|
||||
fileSize: 10,
|
||||
mimeType: 'video/mp4',
|
||||
eventId: 1,
|
||||
totalChunks: 1,
|
||||
});
|
||||
expect(uploadId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects a filename that collapses to nothing', async () => {
|
||||
await expect(
|
||||
chunkedUpload.initializeUpload({
|
||||
filename: '../',
|
||||
fileSize: 10,
|
||||
mimeType: 'video/mp4',
|
||||
eventId: 1,
|
||||
totalChunks: 1,
|
||||
})
|
||||
).rejects.toThrow(/Invalid filename/);
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* Regression tests for reinjectCurrentAdmin — the operator-preservation step of
|
||||
* the .picpeak restore (GHSA-qxfx-4493-4v8f follow-up). Runs against a real
|
||||
* in-memory SQLite DB so the UNIQUE(email)/UNIQUE(username) constraints behave
|
||||
* as in production. Reconciliation is non-destructive (update-in-place / rename,
|
||||
* never delete) so restored rows referenced by FKs keep their ids.
|
||||
*/
|
||||
const knex = require('knex');
|
||||
|
||||
let db;
|
||||
let reinjectCurrentAdmin;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.doMock('../../knexfile', () => ({ client: 'sqlite3' }), { virtual: false });
|
||||
reinjectCurrentAdmin = require('../../src/services/picpeakImportService').reinjectCurrentAdmin;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
|
||||
await db.schema.createTable('admin_users', (t) => {
|
||||
t.increments('id');
|
||||
t.string('username').notNullable().unique();
|
||||
t.string('email').notNullable().unique();
|
||||
t.string('password_hash');
|
||||
t.boolean('is_active').defaultTo(true);
|
||||
t.boolean('must_change_password').defaultTo(false);
|
||||
t.integer('role_id');
|
||||
t.integer('created_by');
|
||||
t.boolean('two_factor_enabled').defaultTo(false);
|
||||
t.string('two_factor_secret');
|
||||
t.text('two_factor_recovery_codes');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => { await db.destroy(); });
|
||||
|
||||
const operator = {
|
||||
id: 1, username: 'admin', email: 'op@example.com',
|
||||
password_hash: 'OP_HASH', is_active: 1, must_change_password: 0, role_id: 1, created_by: 99,
|
||||
two_factor_enabled: 1, two_factor_secret: 'OP_SECRET', two_factor_recovery_codes: '["a","b"]',
|
||||
};
|
||||
|
||||
test('restores login + MFA in place, keeping the row id and its FK columns (FK-safe)', async () => {
|
||||
await db('admin_users').insert({
|
||||
id: 7, username: 'someoneelse', email: 'OP@example.com',
|
||||
password_hash: 'ATTACKER', is_active: 1, must_change_password: 0, role_id: 4, created_by: 5,
|
||||
two_factor_enabled: 0, two_factor_secret: 'ATTACKER_SECRET', two_factor_recovery_codes: null,
|
||||
});
|
||||
await db.transaction((trx) => reinjectCurrentAdmin(trx, operator));
|
||||
|
||||
const rows = await db('admin_users');
|
||||
expect(rows).toHaveLength(1);
|
||||
const row = rows[0];
|
||||
expect(row.id).toBe(7); // id preserved → FK refs hold
|
||||
expect(row.username).toBe('admin');
|
||||
expect(row.password_hash).toBe('OP_HASH');
|
||||
expect(Boolean(row.two_factor_enabled)).toBe(true);
|
||||
expect(row.two_factor_secret).toBe('OP_SECRET'); // attacker MFA secret gone
|
||||
expect(row.two_factor_recovery_codes).toBe('["a","b"]');
|
||||
// Relationship/audit FKs are NOT forced from the operator snapshot (avoids
|
||||
// dangling role_id/created_by on a cross-instance restore) — the restored
|
||||
// row keeps its own already-valid values.
|
||||
expect(row.role_id).toBe(4);
|
||||
expect(row.created_by).toBe(5);
|
||||
});
|
||||
|
||||
test('renames (not deletes) a different row holding the operator username', async () => {
|
||||
await db('admin_users').insert({
|
||||
id: 3, username: 'admin', email: 'other@instance.test',
|
||||
password_hash: 'OTHER', is_active: 1, role_id: 4,
|
||||
});
|
||||
await expect(db.transaction((trx) => reinjectCurrentAdmin(trx, operator))).resolves.not.toThrow();
|
||||
|
||||
const rows = await db('admin_users').orderBy('id');
|
||||
expect(rows).toHaveLength(2); // the other admin survives (FK-safe)
|
||||
const other = rows.find((r) => r.id === 3);
|
||||
expect(other.username).toBe('admin__restored_3'); // renamed, id kept
|
||||
expect(other.email).toBe('other@instance.test');
|
||||
const op = rows.find((r) => r.username === 'admin');
|
||||
expect(op.password_hash).toBe('OP_HASH');
|
||||
});
|
||||
|
||||
test('reconciles email and username colliding with DIFFERENT rows without deleting either', async () => {
|
||||
await db('admin_users').insert([
|
||||
{ id: 4, username: 'someoneelse', email: 'op@example.com', password_hash: 'A', role_id: 4 },
|
||||
{ id: 5, username: 'admin', email: 'other@instance.test', password_hash: 'B', role_id: 4 },
|
||||
]);
|
||||
await expect(db.transaction((trx) => reinjectCurrentAdmin(trx, operator))).resolves.not.toThrow();
|
||||
|
||||
const rows = await db('admin_users').orderBy('id');
|
||||
expect(rows).toHaveLength(2); // both rows survive
|
||||
const opRow = rows.find((r) => r.id === 4); // email match updated in place
|
||||
expect(opRow.username).toBe('admin');
|
||||
expect(opRow.password_hash).toBe('OP_HASH');
|
||||
const renamed = rows.find((r) => r.id === 5); // username holder renamed, not deleted
|
||||
expect(renamed.username).toBe('admin__restored_5');
|
||||
});
|
||||
|
||||
test('inserts the operator with a non-colliding id when neither key exists in the backup', async () => {
|
||||
await db('admin_users').insert({
|
||||
id: 9, username: 'backupadmin', email: 'backup@instance.test', password_hash: 'B', role_id: 1,
|
||||
});
|
||||
await db.transaction((trx) => reinjectCurrentAdmin(trx, operator));
|
||||
|
||||
const rows = await db('admin_users').orderBy('id');
|
||||
expect(rows).toHaveLength(2); // backup admin untouched
|
||||
const opRow = rows.find((r) => r.username === 'admin');
|
||||
expect(opRow.password_hash).toBe('OP_HASH');
|
||||
expect(opRow.id).toBe(10); // max(9)+1, no collision
|
||||
expect(opRow.created_by).toBeNull(); // self-ref FK nulled so the insert can't dangle
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
const path = require('path');
|
||||
const { assertZipEntriesWithin } = require('../../src/utils/safePath');
|
||||
|
||||
describe('assertZipEntriesWithin (ZIP-slip guard, GHSA-jfhw-fj23-fx6x)', () => {
|
||||
const root = path.join('/tmp', 'picpeak-extract-root');
|
||||
|
||||
it('accepts entries that stay within the extraction root', () => {
|
||||
const entries = [
|
||||
{ name: 'photo.jpg' },
|
||||
{ name: 'category/nested/photo.png' },
|
||||
{ name: 'photos_manifest.json' },
|
||||
{ name: 'subdir/' },
|
||||
];
|
||||
expect(() => assertZipEntriesWithin(entries, root)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a parent-traversal entry', () => {
|
||||
const entries = [{ name: '../../uploads/logos/evil.svg' }];
|
||||
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
|
||||
});
|
||||
|
||||
it('rejects an absolute-path entry', () => {
|
||||
const entries = [{ name: '/etc/cron.d/evil' }];
|
||||
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
|
||||
});
|
||||
|
||||
it('rejects when a safe entry is mixed with a traversal entry', () => {
|
||||
const entries = [{ name: 'ok.jpg' }, { name: '../escape.txt' }];
|
||||
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
|
||||
});
|
||||
|
||||
it('tolerates empty / nameless entries', () => {
|
||||
expect(() => assertZipEntriesWithin([{}, { name: '' }, null], root)).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not treat a sibling prefix directory as inside the root', () => {
|
||||
// root is .../picpeak-extract-root; ../picpeak-extract-root-evil must not pass
|
||||
const entries = [{ name: '../picpeak-extract-root-evil/x' }];
|
||||
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* Messages Phase 2 — additional inbound mailboxes + captured message bodies.
|
||||
*
|
||||
* `mail_accounts` holds inbound mailboxes BEYOND the primary accounting IMAP
|
||||
* that already lives in `email_configs` (e.g. the customer `hello@` mailbox).
|
||||
* The intake poller (emailIntakeService) polls the accounting mailbox AND every
|
||||
* enabled row here; customer mail is logged with its body but not routed to the
|
||||
* accounting inbox.
|
||||
*
|
||||
* The new `received_emails` columns capture the parsed message so the Messages
|
||||
* reading pane can show it: `account_key` tags which mailbox it came from,
|
||||
* `body_html`/`body_text` hold the (server-sanitized) body, `to_address` the
|
||||
* envelope recipient. All additive + guarded.
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
const hasAccounts = await knex.schema.hasTable('mail_accounts');
|
||||
if (!hasAccounts) {
|
||||
await knex.schema.createTable('mail_accounts', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('account_key', 64).notNullable().unique(); // e.g. 'customers'
|
||||
t.string('label', 120);
|
||||
t.string('imap_host', 255);
|
||||
t.integer('imap_port').defaultTo(993);
|
||||
t.boolean('imap_secure').defaultTo(true);
|
||||
t.string('imap_user', 255);
|
||||
t.string('imap_pass', 512);
|
||||
t.string('imap_folder', 255).defaultTo('INBOX');
|
||||
t.boolean('enabled').defaultTo(false);
|
||||
t.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
t.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
const cols = [
|
||||
['account_key', (t) => t.string('account_key', 64)],
|
||||
['to_address', (t) => t.string('to_address', 512)],
|
||||
['body_html', (t) => t.text('body_html')],
|
||||
['body_text', (t) => t.text('body_text')],
|
||||
];
|
||||
for (const [name, add] of cols) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const has = await knex.schema.hasColumn('received_emails', name);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (!has) await knex.schema.alterTable('received_emails', add);
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
// Non-destructive on the audit log: leave the added columns in place (they're
|
||||
// nullable and harmless). Only drop the new table.
|
||||
await knex.schema.dropTableIfExists('mail_accounts');
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* Messages Phase 3 — distinguish human-composed sends from system mail.
|
||||
*
|
||||
* `origin` is 'system' for everything the app queues automatically (invoices,
|
||||
* reminders, gallery notices — the Automated stream) and 'manual' for emails an
|
||||
* admin composed/edited in the Messages composer (replies + document messages —
|
||||
* the Customers ▸ Sent stream). Existing rows default to 'system'.
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
const has = await knex.schema.hasColumn('email_queue', 'origin');
|
||||
if (!has) {
|
||||
await knex.schema.alterTable('email_queue', (t) => {
|
||||
t.string('origin', 16).defaultTo('system');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
const has = await knex.schema.hasColumn('email_queue', 'origin');
|
||||
if (has) {
|
||||
await knex.schema.alterTable('email_queue', (t) => {
|
||||
t.dropColumn('origin');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Messages Phase 3 follow-up — outgoing (SMTP) settings per mail account.
|
||||
*
|
||||
* The customer mailbox (hello@) needs BOTH incoming (IMAP, migration 154) and
|
||||
* outgoing (SMTP) config, so replies to customers send from hello@ instead of
|
||||
* the global no-reply@ identity. All additive/guarded.
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
const cols = [
|
||||
['smtp_host', (t) => t.string('smtp_host', 255)],
|
||||
['smtp_port', (t) => t.integer('smtp_port')],
|
||||
['smtp_secure', (t) => t.boolean('smtp_secure').defaultTo(false)],
|
||||
['smtp_user', (t) => t.string('smtp_user', 255)],
|
||||
['smtp_pass', (t) => t.string('smtp_pass', 512)],
|
||||
['from_email', (t) => t.string('from_email', 255)],
|
||||
['from_name', (t) => t.string('from_name', 120)],
|
||||
];
|
||||
for (const [name, add] of cols) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const has = await knex.schema.hasColumn('mail_accounts', name);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (!has) await knex.schema.alterTable('mail_accounts', add);
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
const cols = ['smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_pass', 'from_email', 'from_name'];
|
||||
for (const name of cols) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const has = await knex.schema.hasColumn('mail_accounts', name);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (has) await knex.schema.alterTable('mail_accounts', (t) => t.dropColumn(name));
|
||||
}
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Messages — Archive / Delete (trash) support.
|
||||
*
|
||||
* `mailbox_state` on both mail tables: 'active' (normal folders), 'archived'
|
||||
* (Archived folder), or 'deleted' (Deleted/trash folder). Delete is soft — the
|
||||
* row moves to 'deleted' and is only removed for good when purged FROM the
|
||||
* Deleted folder. Legacy rows have NULL, treated as 'active'. Additive/guarded.
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
for (const table of ['email_queue', 'received_emails']) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const has = await knex.schema.hasColumn(table, 'mailbox_state');
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (!has) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex.schema.alterTable(table, (t) => {
|
||||
t.string('mailbox_state', 16).defaultTo('active');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
for (const table of ['email_queue', 'received_emails']) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const has = await knex.schema.hasColumn(table, 'mailbox_state');
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (has) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex.schema.alterTable(table, (t) => { t.dropColumn('mailbox_state'); });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.45.2",
|
||||
"version": "3.82.4-beta.0",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
+3
-1
@@ -38,6 +38,7 @@ const {
|
||||
|
||||
// Import routes
|
||||
const authRoutes = require('./src/routes/auth');
|
||||
const eventRoutes = require('./src/routes/events');
|
||||
const galleryRoutes = require('./src/routes/gallery');
|
||||
const adminRoutes = require('./src/routes/admin');
|
||||
const adminAuthRoutes = require('./src/routes/adminAuth');
|
||||
@@ -694,7 +695,8 @@ app.get('/health', async (req, res) => {
|
||||
// Routes
|
||||
app.use('/api/setup', setupRoutes); // public first-run bootstrap (self-closes after setup)
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
|
||||
app.use('/api/events', eventRoutes);
|
||||
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
|
||||
// Gallery routes - main routes first, then feedback routes
|
||||
app.use('/api/gallery', galleryRoutes);
|
||||
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
|
||||
|
||||
@@ -9,7 +9,6 @@ const { requirePermission } = require('../middleware/permissions');
|
||||
const archiver = require('archiver');
|
||||
const StreamZip = require('node-stream-zip');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const { assertZipEntriesWithin } = require('../utils/safePath');
|
||||
const logger = require('../utils/logger');
|
||||
const { getPagination } = require('../utils/routeHelpers');
|
||||
const router = express.Router();
|
||||
@@ -184,16 +183,6 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
||||
const entries = Object.values(await zip.entries());
|
||||
logger.info(`Archive contains ${entries.length} entries`);
|
||||
|
||||
// Reject ZIP-slip entries before writing anything to disk — extract()
|
||||
// does not neutralise `../` in entry names (GHSA-jfhw-fj23-fx6x).
|
||||
try {
|
||||
assertZipEntriesWithin(entries, eventDir);
|
||||
} catch (slipErr) {
|
||||
await zip.close();
|
||||
logger.warn(`Refusing archive restore — unsafe entry path: ${slipErr.message}`);
|
||||
return res.status(400).json({ error: 'Archive contains invalid entry paths' });
|
||||
}
|
||||
|
||||
// Stream-extract everything to disk
|
||||
await zip.extract(null, eventDir);
|
||||
await zip.close();
|
||||
|
||||
@@ -2,8 +2,6 @@ const express = require('express');
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { clearAdminAuthCookie } = require('../utils/tokenUtils');
|
||||
const { revokeToken } = require('../utils/tokenRevocation');
|
||||
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
|
||||
const logger = require('../utils/logger');
|
||||
const { errorResponse, getPagination } = require('../utils/routeHelpers');
|
||||
@@ -180,43 +178,12 @@ router.post('/picpeak/import', adminAuth, requirePermission('backup.restore'), p
|
||||
const picpeakPath = req.file.path;
|
||||
try {
|
||||
const { importFromPicpeak } = require('../services/picpeakImportService');
|
||||
// adminAuth populates req.admin, not req.user. Passing req.user.id here
|
||||
// left currentAdminId undefined, so reinjectCurrentAdmin() had no account
|
||||
// to preserve and the admin_users table was fully replaced by the backup —
|
||||
// letting a crafted .picpeak take over every admin account (GHSA-qxfx-4493-4v8f).
|
||||
const result = await importFromPicpeak({ picpeakPath, currentAdminId: req.admin && req.admin.id });
|
||||
|
||||
// The restore rewrote admin_users, so ids may have shifted. The operator's
|
||||
// current JWT is bound only to the pre-restore admin id (adminAuth trusts
|
||||
// `decoded.id` — IP is logged, not enforced, and the backup controls
|
||||
// password_changed_at), which could now resolve to a DIFFERENT restored
|
||||
// account and silently grant its permissions. Force a fresh login instead
|
||||
// of trusting the old session: revoke the token and clear the cookie.
|
||||
// Clearing the cookie is the guarantee — it drops the operator's browser
|
||||
// session unconditionally. Revocation is the extra layer that also kills a
|
||||
// Bearer-header copy of the JWT; revokeToken() swallows DB errors and
|
||||
// returns false, so check the result and log loudly if the denylist write
|
||||
// didn't land (the operator should still re-login, which the cookie clear
|
||||
// forces).
|
||||
let tokenRevoked = false;
|
||||
try {
|
||||
if (req.token) {
|
||||
tokenRevoked = await revokeToken(req.token, 'picpeak-import', { adminId: req.admin && req.admin.id });
|
||||
}
|
||||
} catch (revokeErr) {
|
||||
logger.warn('[picpeak-import] failed to revoke session token after restore', { error: revokeErr.message });
|
||||
}
|
||||
if (req.token && !tokenRevoked) {
|
||||
logger.warn('[picpeak-import] session token was NOT added to the revocation denylist after restore; relying on cookie clear to force re-login');
|
||||
}
|
||||
clearAdminAuthCookie(res);
|
||||
|
||||
const result = await importFromPicpeak({ picpeakPath, currentAdminId: req.user && req.user.id });
|
||||
res.json({
|
||||
success: true,
|
||||
tables: result.tables,
|
||||
filesRestored: result.filesRestored,
|
||||
usesExternalMedia: result.usesExternalMedia,
|
||||
sessionInvalidated: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const status = error.statusCode || 500;
|
||||
|
||||
@@ -4,10 +4,6 @@ const { body, query, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
// Gate the NEW Messages routes on the `messaging` flag (per-route, NOT the whole
|
||||
// /email mount — the pre-existing config/queue/received endpoints stay ungated).
|
||||
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
||||
const messagingGate = requireFeatureFlag('messaging');
|
||||
const { wrapEmailHtml, processEmailQueue } = require('../services/emailProcessor');
|
||||
const { errorResponse } = require('../utils/routeHelpers');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -264,196 +260,16 @@ router.get('/received', adminAuth, requirePermission('email.view'), async (req,
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize, 10) || 25));
|
||||
const account = req.query.account ? String(req.query.account) : null;
|
||||
// mailbox_state filter: no param → active (+ legacy NULL); else exact.
|
||||
const state = ['archived', 'deleted'].includes(String(req.query.state)) ? String(req.query.state) : 'active';
|
||||
// Optional full-table search (sender / subject) so results aren't truncated
|
||||
// to the first page before matching.
|
||||
const q = req.query.q ? String(req.query.q).trim().slice(0, 255) : '';
|
||||
// 'accounting' matches legacy rows too (account_key was NULL before mig 154).
|
||||
const applyAccount = (qb) => {
|
||||
if (account === 'accounting') qb.where((b) => b.where('account_key', 'accounting').orWhereNull('account_key'));
|
||||
else if (account) qb.where('account_key', account);
|
||||
if (state === 'active') qb.where((b) => b.where('mailbox_state', 'active').orWhereNull('mailbox_state'));
|
||||
else qb.where('mailbox_state', state);
|
||||
if (q) qb.where((b) => b.where('from_address', 'like', `%${q}%`).orWhere('subject', 'like', `%${q}%`));
|
||||
return qb;
|
||||
};
|
||||
const countRow = await applyAccount(db('received_emails')).count({ c: '*' }).first();
|
||||
const base = db('received_emails');
|
||||
const countRow = await base.clone().count({ c: '*' }).first();
|
||||
const total = parseInt(countRow?.c || 0, 10);
|
||||
// Bodies are excluded from the list (can be large); fetched per-message.
|
||||
const items = await applyAccount(db('received_emails'))
|
||||
.select('id', 'message_id', 'account_key', 'from_address', 'to_address', 'subject',
|
||||
'received_at', 'attachment_count', 'status', 'inbound_document_id', 'error')
|
||||
.orderBy('received_at', 'desc').limit(pageSize).offset((page - 1) * pageSize);
|
||||
const items = await base.clone().orderBy('received_at', 'desc').limit(pageSize).offset((page - 1) * pageSize);
|
||||
res.json({ items, pagination: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) } });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch received emails');
|
||||
}
|
||||
});
|
||||
|
||||
// Single received email WITH its captured (server-sanitized) body — Messages
|
||||
// reading pane. body_html was already sanitized on ingest; the viewer renders
|
||||
// it in a script-less sandboxed iframe as well.
|
||||
router.get('/received/:id', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' });
|
||||
const row = await db('received_emails').where({ id }).first();
|
||||
if (!row) return res.status(404).json({ error: 'Email not found' });
|
||||
res.json(row);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch email');
|
||||
}
|
||||
});
|
||||
|
||||
// Move an email between mailbox states: Archive / Delete (soft) or Restore
|
||||
// (back to active). kind = 'queue' | 'received'. Delete is a soft move to the
|
||||
// trash; the row is only removed for good by the DELETE handler below.
|
||||
router.post('/item/:kind/:id/state', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const table = req.params.kind === 'received' ? 'received_emails' : req.params.kind === 'queue' ? 'email_queue' : null;
|
||||
if (!table) return res.status(400).json({ error: 'Invalid kind' });
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' });
|
||||
const state = String(req.body?.state || '');
|
||||
if (!['active', 'archived', 'deleted'].includes(state)) return res.status(400).json({ error: 'Invalid state' });
|
||||
const n = await db(table).where({ id }).update({ mailbox_state: state });
|
||||
if (!n) return res.status(404).json({ error: 'Not found' });
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update email');
|
||||
}
|
||||
});
|
||||
|
||||
// Permanently delete an email row — only offered from the Deleted folder.
|
||||
router.delete('/item/:kind/:id', adminAuth, messagingGate, requirePermission('email.edit'), async (req, res) => {
|
||||
try {
|
||||
const table = req.params.kind === 'received' ? 'received_emails' : req.params.kind === 'queue' ? 'email_queue' : null;
|
||||
if (!table) return res.status(400).json({ error: 'Invalid kind' });
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' });
|
||||
await db(table).where({ id }).del();
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to delete email');
|
||||
}
|
||||
});
|
||||
|
||||
// Additional inbound mailboxes (beyond the primary accounting IMAP in
|
||||
// email_configs) — e.g. the customer hello@ box. Passwords are masked out.
|
||||
router.get('/accounts', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const rows = await db('mail_accounts').orderBy('id');
|
||||
res.json({ items: rows.map((a) => ({
|
||||
...a,
|
||||
imap_pass: a.imap_pass ? '********' : '',
|
||||
smtp_pass: a.smtp_pass ? '********' : '',
|
||||
})) });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to load mail accounts');
|
||||
}
|
||||
});
|
||||
|
||||
// Resolved sender/mailbox addresses for the Messages UI — so the sidebar shows
|
||||
// the REAL configured addresses instead of hardcoded placeholders. Accounting =
|
||||
// the primary IMAP login (rechnungen@); customers = the hello@ mailbox; the
|
||||
// automated stream sends from the global SMTP from-address.
|
||||
router.get('/identities', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const cfg = await db('email_configs').first();
|
||||
let customers = null;
|
||||
try {
|
||||
const cust = await db('mail_accounts').where({ account_key: 'customers' }).first();
|
||||
customers = cust?.imap_user || cust?.from_email || null;
|
||||
} catch (_) { customers = null; }
|
||||
res.json({
|
||||
automated: cfg?.from_email || null,
|
||||
accounting: cfg?.imap_user || null,
|
||||
customers,
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to load mail identities');
|
||||
}
|
||||
});
|
||||
|
||||
// Upsert a mailbox by account_key. A masked password ('********') keeps the
|
||||
// stored value so the admin never has to re-type it.
|
||||
router.post('/accounts', adminAuth, messagingGate, requirePermission('email.edit'), async (req, res) => {
|
||||
try {
|
||||
const b = req.body || {};
|
||||
if (!b.account_key) return res.status(400).json({ error: 'account_key is required' });
|
||||
// SSRF guard — mirror /config + /incoming-config: neither the IMAP nor the
|
||||
// SMTP host may point at a private/internal address.
|
||||
const { isPrivateIP } = require('../utils/networkValidation');
|
||||
if (b.imap_host && isPrivateIP(b.imap_host)) {
|
||||
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
|
||||
}
|
||||
if (b.smtp_host && isPrivateIP(b.smtp_host)) {
|
||||
return res.status(400).json({ error: 'SMTP host cannot point to a private or internal network address' });
|
||||
}
|
||||
const patch = {
|
||||
label: b.label || null,
|
||||
imap_host: b.imap_host || null,
|
||||
imap_port: b.imap_port ? parseInt(b.imap_port, 10) : 993,
|
||||
imap_secure: b.imap_secure !== false,
|
||||
imap_user: b.imap_user || null,
|
||||
imap_folder: b.imap_folder || 'INBOX',
|
||||
// Outgoing (SMTP) identity — replies from this mailbox send from here.
|
||||
smtp_host: b.smtp_host || null,
|
||||
smtp_port: b.smtp_port ? parseInt(b.smtp_port, 10) : 587,
|
||||
smtp_secure: b.smtp_secure === true,
|
||||
smtp_user: b.smtp_user || null,
|
||||
from_email: b.from_email || null,
|
||||
from_name: b.from_name || null,
|
||||
enabled: !!b.enabled,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (b.imap_pass && b.imap_pass !== '********') patch.imap_pass = b.imap_pass;
|
||||
if (b.smtp_pass && b.smtp_pass !== '********') patch.smtp_pass = b.smtp_pass;
|
||||
const existing = await db('mail_accounts').where({ account_key: b.account_key }).first();
|
||||
if (existing) {
|
||||
await db('mail_accounts').where({ account_key: b.account_key }).update(patch);
|
||||
} else {
|
||||
await db('mail_accounts').insert({
|
||||
account_key: b.account_key,
|
||||
imap_pass: (b.imap_pass && b.imap_pass !== '********') ? b.imap_pass : '',
|
||||
smtp_pass: (b.smtp_pass && b.smtp_pass !== '********') ? b.smtp_pass : '',
|
||||
created_at: new Date(),
|
||||
...patch,
|
||||
});
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to save mail account');
|
||||
}
|
||||
});
|
||||
|
||||
// Test an inbound mailbox's IMAP connection (before or after saving). Resolves
|
||||
// a masked/blank password from the stored row for the given account_key.
|
||||
router.post('/accounts/test', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const b = req.body || {};
|
||||
const { isPrivateIP } = require('../utils/networkValidation');
|
||||
if (b.imap_host && isPrivateIP(b.imap_host)) {
|
||||
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
|
||||
}
|
||||
let pass = b.imap_pass;
|
||||
if ((!pass || pass === '********') && b.account_key) {
|
||||
const stored = await db('mail_accounts').where({ account_key: b.account_key }).first();
|
||||
pass = stored?.imap_pass || '';
|
||||
}
|
||||
const emailIntakeService = require('../services/emailIntakeService');
|
||||
const result = await emailIntakeService.testConnection({
|
||||
host: b.imap_host, port: b.imap_port, secure: b.imap_secure,
|
||||
user: b.imap_user, pass, folder: b.imap_folder || 'INBOX',
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(422).json({ ok: false, error: `Mailbox test failed (${error.message}).` });
|
||||
}
|
||||
});
|
||||
|
||||
// Test email configuration
|
||||
router.post('/test', adminAuth, requirePermission('email.send'), async (req, res) => {
|
||||
try {
|
||||
@@ -622,8 +438,6 @@ router.post('/flush-queue', adminAuth, requirePermission('email.send'), async (r
|
||||
router.get('/queue', adminAuth, requirePermission('email.view'), [
|
||||
query('status').optional({ values: 'falsy' }).isIn(['pending', 'sent', 'failed']),
|
||||
query('emailType').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
|
||||
query('origin').optional({ values: 'falsy' }).isIn(['system', 'manual']),
|
||||
query('state').optional({ values: 'falsy' }).isIn(['active', 'archived', 'deleted']),
|
||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||
query('from').optional({ values: 'falsy' }).isISO8601(),
|
||||
query('to').optional({ values: 'falsy' }).isISO8601(),
|
||||
@@ -642,13 +456,6 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
|
||||
const applyFilters = (qb) => {
|
||||
if (req.query.status) qb.where('email_queue.status', req.query.status);
|
||||
if (req.query.emailType) qb.where('email_queue.email_type', req.query.emailType);
|
||||
// 'system' includes legacy rows (origin was NULL before migration 155).
|
||||
if (req.query.origin === 'manual') qb.where('email_queue.origin', 'manual');
|
||||
else if (req.query.origin === 'system') qb.where((b) => b.where('email_queue.origin', 'system').orWhereNull('email_queue.origin'));
|
||||
// mailbox_state: default active (+ legacy NULL); Archived/Deleted folders pass it explicitly.
|
||||
const st = ['archived', 'deleted'].includes(String(req.query.state)) ? String(req.query.state) : 'active';
|
||||
if (st === 'active') qb.where((b) => b.where('email_queue.mailbox_state', 'active').orWhereNull('email_queue.mailbox_state'));
|
||||
else qb.where('email_queue.mailbox_state', st);
|
||||
if (req.query.from) qb.where('email_queue.created_at', '>=', new Date(req.query.from));
|
||||
if (req.query.to) qb.where('email_queue.created_at', '<=', new Date(req.query.to));
|
||||
if (req.query.q) {
|
||||
@@ -677,7 +484,6 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
|
||||
'email_queue.sent_at',
|
||||
'email_queue.error_message',
|
||||
'email_queue.retry_count',
|
||||
'email_queue.origin',
|
||||
'email_queue.event_id',
|
||||
'events.event_name as event_name',
|
||||
'events.slug as event_slug'
|
||||
@@ -697,7 +503,6 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
|
||||
sentAt: r.sent_at,
|
||||
errorMessage: r.error_message,
|
||||
retryCount: r.retry_count,
|
||||
origin: r.origin || 'system',
|
||||
eventId: r.event_id,
|
||||
eventName: r.event_name || null,
|
||||
eventSlug: r.event_slug || null,
|
||||
@@ -713,111 +518,6 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
|
||||
}
|
||||
});
|
||||
|
||||
// Single queued/sent email WITH its rendered body — powers the Messages
|
||||
// reading pane. `rendered_html` is the exact HTML that was sent (migration
|
||||
// 119); rows sent before that migration have none. Attachment disk paths in
|
||||
// `email_data` are never exposed — only the filenames, so the pane can list
|
||||
// attachments without leaking storage paths (same PII posture as the list).
|
||||
router.get('/queue/:id', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' });
|
||||
const row = await db('email_queue')
|
||||
.leftJoin('events', 'events.id', 'email_queue.event_id')
|
||||
.select('email_queue.*', 'events.event_name as event_name', 'events.slug as event_slug')
|
||||
.where('email_queue.id', id)
|
||||
.first();
|
||||
if (!row) return res.status(404).json({ error: 'Email not found' });
|
||||
|
||||
let cc = null;
|
||||
let attachments = [];
|
||||
try {
|
||||
const data = row.email_data ? JSON.parse(row.email_data) : {};
|
||||
if (data.cc) cc = Array.isArray(data.cc) ? data.cc.join(', ') : String(data.cc);
|
||||
if (Array.isArray(data.attachments)) {
|
||||
attachments = data.attachments
|
||||
.filter((a) => a && a.filename)
|
||||
.map((a) => ({ filename: a.filename, contentType: a.contentType || null }));
|
||||
}
|
||||
} catch (_) { /* malformed email_data → no cc/attachments, still return the body */ }
|
||||
|
||||
res.json({
|
||||
id: row.id,
|
||||
recipientEmail: row.recipient_email,
|
||||
emailType: row.email_type,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
scheduledAt: row.scheduled_at,
|
||||
sentAt: row.sent_at,
|
||||
errorMessage: row.error_message,
|
||||
retryCount: row.retry_count,
|
||||
eventId: row.event_id,
|
||||
eventName: row.event_name || null,
|
||||
eventSlug: row.event_slug || null,
|
||||
renderedHtml: row.rendered_html || null,
|
||||
cc,
|
||||
attachments,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get email queue item error:', error);
|
||||
res.status(500).json({ error: 'Failed to load email', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Send a human-composed email from the Messages composer. The admin already
|
||||
// edited the body (reply or document message), so it is sent as-is — no
|
||||
// template render — after a sanitize pass. Recorded in email_queue as a
|
||||
// 'manual' send so it surfaces under Customers > Sent.
|
||||
router.post('/send', adminAuth, messagingGate, requirePermission('email.send'), async (req, res) => {
|
||||
try {
|
||||
const b = req.body || {};
|
||||
const to = String(b.to || '').trim();
|
||||
const subject = String(b.subject || '').trim();
|
||||
if (!to || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to)) {
|
||||
return res.status(400).json({ error: 'A valid recipient email is required.' });
|
||||
}
|
||||
if (!subject) return res.status(400).json({ error: 'A subject is required.' });
|
||||
|
||||
const sanitizeHtml = require('sanitize-html');
|
||||
// Match the stricter inbound sanitizeBody allowlist: no <style> tag, no
|
||||
// data: scheme — inline style/class attributes are enough for composed mail.
|
||||
const html = sanitizeHtml(String(b.html || ''), {
|
||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
|
||||
allowedAttributes: {
|
||||
...sanitizeHtml.defaults.allowedAttributes,
|
||||
img: ['src', 'alt', 'width', 'height'],
|
||||
'*': ['style', 'class'],
|
||||
},
|
||||
allowedSchemes: ['http', 'https', 'mailto', 'cid'],
|
||||
});
|
||||
const cc = b.cc ? String(b.cc).trim() : null;
|
||||
const accountKey = b.accountKey ? String(b.accountKey) : undefined;
|
||||
|
||||
const emailProcessor = require('../services/emailProcessor');
|
||||
const result = await emailProcessor.sendRawEmail({ to, cc, subject, html, accountKey });
|
||||
|
||||
await db('email_queue').insert({
|
||||
recipient_email: to,
|
||||
email_type: 'manual_message',
|
||||
email_data: JSON.stringify({
|
||||
subject,
|
||||
cc: cc || undefined,
|
||||
replyToReceivedId: b.replyToReceivedId || undefined,
|
||||
messageId: result.messageId,
|
||||
}),
|
||||
status: 'sent',
|
||||
origin: 'manual',
|
||||
rendered_html: html,
|
||||
created_at: new Date(),
|
||||
sent_at: new Date(),
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('Manual send error:', error);
|
||||
res.status(500).json({ error: 'Failed to send message', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Helper: parse variables JSON safely
|
||||
function parseVariables(template) {
|
||||
try {
|
||||
|
||||
@@ -1596,51 +1596,4 @@ module.exports = (router) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Extend a gallery's expiration. Migrated from the legacy /api/events router
|
||||
// (removed — GHSA-4j34-x562-5vfq), now on the canonical mount with the same
|
||||
// permission + ownership guards as every other gallery mutation, so a
|
||||
// non-owning editor/viewer can no longer touch a gallery they don't own.
|
||||
router.post('/:id/extend', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
|
||||
body('days').isInt({ min: 1, max: 365 })
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { days } = req.body;
|
||||
|
||||
let eventQuery = db('events').where('id', id);
|
||||
// Editor role can only touch their own events (defence in depth alongside
|
||||
// requireEventOwnership).
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
const newExpiration = new Date(event.expires_at);
|
||||
newExpiration.setDate(newExpiration.getDate() + days);
|
||||
|
||||
await db('events').where('id', id).update({
|
||||
expires_at: newExpiration,
|
||||
is_active: formatBoolean(true) // reactivate if it had expired
|
||||
});
|
||||
|
||||
await logActivity('event_expiration_extended',
|
||||
{ eventName: event.event_name, days },
|
||||
id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ expires_at: newExpiration });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to extend expiration');
|
||||
}
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
@@ -254,19 +254,6 @@ router.patch('/:id/enabled', requirePermission('workflows.manage'), async (req,
|
||||
// enabled state on the next SEED_VERSION bump (review nit #1).
|
||||
if (await hasColumnCached('workflows', 'admin_toggled_at')) patch.admin_toggled_at = db.fn.now();
|
||||
await db('workflows').where({ id }).update(patch);
|
||||
// Turning dunning ON enrolls existing open/unpaid invoices (anchored to
|
||||
// their due date) so it starts chasing current debtors, not only invoices
|
||||
// sent after enabling (#750). Scoped to this flow's id so the backfill only
|
||||
// enrolls dunning, not any custom invoice.sent flow. Best-effort — never
|
||||
// fail the toggle over it.
|
||||
if (enabled && wf.builtin_key === 'invoice_dunning') {
|
||||
try {
|
||||
const n = await require('../services/workflows').backfillDunningRuns(id);
|
||||
require('../utils/logger').info('[workflow] dunning enabled — enrolled existing invoices', { enrolled: n });
|
||||
} catch (e) {
|
||||
require('../utils/logger').warn('[workflow] dunning backfill failed', { error: e.message });
|
||||
}
|
||||
}
|
||||
res.json({ id, enabled });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
@@ -543,18 +543,6 @@ router.post('/gallery/share-login', [
|
||||
return res.status(401).json({ error: 'Invalid or expired share link' });
|
||||
}
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
// The share link only proves the holder was given the link — it is NOT the
|
||||
// gallery password. For a password-protected gallery, minting a full
|
||||
// `type:'gallery'` token here would let anyone with the share URL bypass
|
||||
// the password entirely (GHSA-9hmx-68vc-qpqw). Signal that a password is
|
||||
// still required and return WITHOUT a token/cookie; the client then goes
|
||||
// through POST /gallery/verify, which does check the password.
|
||||
if (requiresPassword) {
|
||||
return res.json({ requires_password: true });
|
||||
}
|
||||
|
||||
const jwtToken = jwt.sign({
|
||||
eventId: event.id,
|
||||
eventSlug: event.slug,
|
||||
@@ -569,6 +557,8 @@ router.post('/gallery/share-login', [
|
||||
await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent);
|
||||
setGalleryAuthCookies(res, jwtToken, event.slug);
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
res.json({
|
||||
token: jwtToken,
|
||||
event: {
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { slugify } = require('../utils/slug');
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const router = express.Router();
|
||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
||||
const eventTypeService = require('../services/eventTypeService');
|
||||
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Use parseStringInput from shared parsers for customer data extraction
|
||||
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
|
||||
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
|
||||
const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone);
|
||||
|
||||
// Whether the global "phone field" toggle (#322) is enabled. Same shape as
|
||||
// the helper in adminEvents.js — kept local so this route doesn't import
|
||||
// from a sibling route file.
|
||||
const isPhoneFieldEnabled = async () => {
|
||||
try {
|
||||
const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first();
|
||||
if (!row) return false;
|
||||
let value = row.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
try { value = JSON.parse(value); } catch { /* keep raw */ }
|
||||
}
|
||||
return value === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const mapEventForApi = (event) => {
|
||||
if (!event || typeof event !== 'object') {
|
||||
return event;
|
||||
}
|
||||
|
||||
const {
|
||||
host_name,
|
||||
host_email,
|
||||
customer_name,
|
||||
customer_email,
|
||||
...rest
|
||||
} = event;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
customer_name: customer_name ?? host_name ?? null,
|
||||
customer_email: customer_email ?? host_email ?? null
|
||||
};
|
||||
};
|
||||
|
||||
let customerColumnCache = null;
|
||||
const hasCustomerContactColumns = async () => {
|
||||
if (customerColumnCache === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
|
||||
if (hasColumn) {
|
||||
customerColumnCache = true;
|
||||
}
|
||||
return hasColumn;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, [
|
||||
body('event_type').notEmpty().trim().custom(async (value) => {
|
||||
const isValid = await eventTypeService.isValidEventType(value);
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid event type');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('event_name').notEmpty(),
|
||||
body('event_date').isDate(),
|
||||
body('customer_name').notEmpty().trim(),
|
||||
body('customer_email').isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
|
||||
body('customer_phone').optional({ nullable: true, checkFalsy: true })
|
||||
.isString().trim()
|
||||
.isLength({ max: 32 }).withMessage('Phone number must be at most 32 characters'),
|
||||
body('admin_email').isEmail(),
|
||||
body('require_password').optional().isBoolean(),
|
||||
body('password').optional().isString().custom((value, { req }) => {
|
||||
const requirePassword = parseBooleanInput(req.body.require_password, true);
|
||||
if (!requirePassword) {
|
||||
return true;
|
||||
}
|
||||
if (typeof value !== 'string' || value.trim().length < 6) {
|
||||
throw new Error('Password must be at least 6 characters long');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('expiration_days').isInt({ min: 1, max: 365 }).optional()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const {
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
admin_email,
|
||||
password,
|
||||
require_password: requirePasswordInput = true,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
expiration_days = 30
|
||||
} = req.body;
|
||||
|
||||
const customerEmail = getCustomerEmailFromPayload(req.body);
|
||||
const customerName = getCustomerNameFromPayload(req.body);
|
||||
|
||||
if (!customerName || !customerEmail) {
|
||||
return res.status(400).json({ error: 'customer_name and customer_email are required' });
|
||||
}
|
||||
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
const phoneEnabled = await isPhoneFieldEnabled();
|
||||
const customerPhone = phoneEnabled ? getCustomerPhoneFromPayload(req.body) : null;
|
||||
|
||||
const requirePassword = parseBooleanInput(requirePasswordInput, true);
|
||||
|
||||
if (requirePassword) {
|
||||
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||
eventName: event_name
|
||||
});
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.errors,
|
||||
score: passwordValidation.score,
|
||||
feedback: passwordValidation.feedback
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique slug — slugify() handles accents (see #525).
|
||||
const baseSlug = `${event_type}-${slugify(event_name)}-${event_date}`;
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
while (await db('events').where({ slug }).first()) {
|
||||
slug = `${baseSlug}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Generate share link variants (auto-detects short URL preference)
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
|
||||
// Hash password (or placeholder when not required)
|
||||
const password_hash = requirePassword
|
||||
? await bcrypt.hash(password, getBcryptRounds())
|
||||
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
|
||||
// Calculate expiration date (days after event date)
|
||||
const expires_at = new Date(event_date);
|
||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||
|
||||
// Create folder structure
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const eventPath = path.join(storagePath, 'events/active', slug);
|
||||
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
|
||||
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
|
||||
|
||||
// Insert into database
|
||||
const insertResult = await db('events').insert({
|
||||
slug,
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
|
||||
...(customerPhone ? { customer_phone: customerPhone } : {}),
|
||||
host_name: customerName,
|
||||
host_email: customerEmail,
|
||||
admin_email,
|
||||
password_hash,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at,
|
||||
require_password: formatBoolean(requirePassword)
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
const eventId = insertResult[0]?.id || insertResult[0];
|
||||
|
||||
// Queue creation email
|
||||
const { queueEmail } = require('../services/emailProcessor');
|
||||
await queueEmail(eventId, customerEmail, 'gallery_created', {
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
host_name: customerName,
|
||||
event_name,
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: requirePassword ? password : 'No password required',
|
||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
});
|
||||
|
||||
// WhatsApp gallery_ready notification (#647 follow-up). Mirrors the
|
||||
// adminEvents.js path: fires when the customer supplied a phone, the
|
||||
// feature is enabled, and a config exists. Non-fatal — a queue failure
|
||||
// must never block gallery creation.
|
||||
if (customerPhone) {
|
||||
try {
|
||||
const { queueWhatsapp, getWhatsAppConfig } = require('../services/whatsappProcessor');
|
||||
const waConfig = await getWhatsAppConfig();
|
||||
if (waConfig && waConfig.enabled) {
|
||||
await queueWhatsapp(eventId, customerPhone, 'gallery_created', {
|
||||
customer_name: customerName || '',
|
||||
event_name,
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: requirePassword ? password : '',
|
||||
expiry_date: expires_at ? expires_at.toISOString() : null,
|
||||
language: null,
|
||||
});
|
||||
}
|
||||
} catch (waError) {
|
||||
logger.warn('Failed to queue WhatsApp notification on create', waError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Webhook lifecycle (#327). Legacy public endpoint — events go live
|
||||
// immediately so created + published fire together. Payload uses the
|
||||
// canonical event subject (#341) — every event.* webhook now includes
|
||||
// customer contact + share_token.
|
||||
try {
|
||||
const webhookService = require('../services/webhookService');
|
||||
const eventSubject = webhookService.buildEventSubject({
|
||||
id: eventId,
|
||||
slug,
|
||||
event_name,
|
||||
event_type,
|
||||
event_date,
|
||||
share_url: shareUrl,
|
||||
share_token: shareToken,
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
customer_phone: customerPhone,
|
||||
});
|
||||
await webhookService.fire('event.created', { event: eventSubject });
|
||||
await webhookService.fire('event.published', { event: eventSubject });
|
||||
} catch (e) { /* non-fatal */ }
|
||||
|
||||
res.json({
|
||||
id: eventId,
|
||||
slug,
|
||||
share_link: shareUrl,
|
||||
expires_at,
|
||||
require_password: requirePassword,
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
res.status(500).json({ error: 'Failed to create event' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all events (admin)
|
||||
router.get('/', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { status = 'all' } = req.query;
|
||||
|
||||
let query = db('events').select('*');
|
||||
|
||||
if (status === 'active') {
|
||||
query = query.where('is_active', formatBoolean(true));
|
||||
} else if (status === 'archived') {
|
||||
query = query.where('is_archived', formatBoolean(true));
|
||||
}
|
||||
|
||||
const events = await query.orderBy('created_at', 'desc');
|
||||
|
||||
// Add photo counts
|
||||
for (const event of events) {
|
||||
const photoCount = await db('photos').where('event_id', event.id).count('id as count').first();
|
||||
event.photo_count = photoCount.count;
|
||||
}
|
||||
|
||||
res.json(events.map(mapEventForApi));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch events' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update event
|
||||
router.put('/:id', adminAuth, [
|
||||
body('customer_name').optional().trim().notEmpty(),
|
||||
body('customer_email').optional().isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
|
||||
body('require_password').optional().isBoolean()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const updates = { ...req.body };
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
|
||||
// Don't allow updating certain fields
|
||||
delete updates.id;
|
||||
delete updates.slug;
|
||||
delete updates.created_at;
|
||||
delete updates.password_confirmation;
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) {
|
||||
return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' });
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'customer_name')) {
|
||||
const nextName = getCustomerNameFromPayload(updates);
|
||||
if (nextName) {
|
||||
if (customerColumnsAvailable) {
|
||||
updates.customer_name = nextName;
|
||||
} else {
|
||||
delete updates.customer_name;
|
||||
}
|
||||
updates.host_name = nextName;
|
||||
} else {
|
||||
delete updates.customer_name;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'customer_email')) {
|
||||
const nextEmail = getCustomerEmailFromPayload(updates);
|
||||
if (nextEmail) {
|
||||
if (customerColumnsAvailable) {
|
||||
updates.customer_email = nextEmail;
|
||||
} else {
|
||||
delete updates.customer_email;
|
||||
}
|
||||
updates.host_email = nextEmail;
|
||||
} else {
|
||||
delete updates.customer_email;
|
||||
}
|
||||
}
|
||||
|
||||
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
|
||||
let requirePasswordUpdate;
|
||||
if (hasRequirePasswordUpdate) {
|
||||
requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
|
||||
updates.require_password = formatBoolean(requirePasswordUpdate);
|
||||
}
|
||||
|
||||
let newPasswordPlain;
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'password')) {
|
||||
if (updates.password === undefined || updates.password === null || updates.password === '') {
|
||||
delete updates.password;
|
||||
} else {
|
||||
newPasswordPlain = updates.password;
|
||||
delete updates.password;
|
||||
}
|
||||
}
|
||||
|
||||
const event = await db('events').where('id', id).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
const currentRequirePassword = parseBooleanInput(event.require_password, true);
|
||||
|
||||
if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) {
|
||||
return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' });
|
||||
}
|
||||
|
||||
if (newPasswordPlain) {
|
||||
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
|
||||
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
|
||||
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
}
|
||||
|
||||
await db('events').where('id', id).update(updates);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to update event' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete event (mark as inactive)
|
||||
router.delete('/:id', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
await db('events').where('id', id).update({ is_active: formatBoolean(false) });
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to delete event' });
|
||||
}
|
||||
});
|
||||
|
||||
// Extend expiration
|
||||
router.post('/:id/extend', adminAuth, [
|
||||
body('days').isInt({ min: 1, max: 365 })
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { days } = req.body;
|
||||
|
||||
const event = await db('events').where('id', id).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
const newExpiration = new Date(event.expires_at);
|
||||
newExpiration.setDate(newExpiration.getDate() + days);
|
||||
|
||||
await db('events').where('id', id).update({
|
||||
expires_at: newExpiration,
|
||||
is_active: formatBoolean(true) // Reactivate if expired
|
||||
});
|
||||
|
||||
res.json({ expires_at: newExpiration });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to extend expiration' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -31,10 +31,7 @@ function buildDunningGraph({ firstDays, gapDays, maxReminders }) {
|
||||
const nodes = [
|
||||
{ node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
|
||||
{ node_key: 'waitDue', type: 'wait', config: { untilVar: 'dueDate' }, pos_x: 240, pos_y: 110 },
|
||||
// Anchor the grace period to the invoice's due date (dueDate + firstDays),
|
||||
// not "now + firstDays" — so an already-overdue invoice enrolled via backfill
|
||||
// duns on its real timeline instead of restarting a fresh grace clock (#750).
|
||||
{ node_key: 'waitGrace', type: 'wait', config: { untilVar: 'dueDate', delayDays: firstDays }, pos_x: 240, pos_y: 220 },
|
||||
{ node_key: 'waitGrace', type: 'wait', config: { delayDays: firstDays }, pos_x: 240, pos_y: 220 },
|
||||
{ node_key: 'loop', type: 'loop', config: { maxIterations: maxReminders }, pos_x: 240, pos_y: 330 },
|
||||
{ node_key: 'checkPaid', type: 'condition', config: { condition: 'invoice_paid' }, pos_x: 240, pos_y: 440 },
|
||||
{ node_key: 'paymentCheck', type: 'action', config: { action: 'queue_payment_check' }, pos_x: 240, pos_y: 550 },
|
||||
@@ -221,7 +218,7 @@ function buildGalleryExpiredGraph() {
|
||||
const BUILTINS = [
|
||||
{
|
||||
key: DUNNING_KEY,
|
||||
version: 7,
|
||||
version: 6,
|
||||
enabled: false,
|
||||
name: 'Invoice dunning (built-in)',
|
||||
trigger_type: 'invoice.sent',
|
||||
|
||||
@@ -30,16 +30,6 @@ async function initializeUpload(options) {
|
||||
totalChunks
|
||||
} = options;
|
||||
|
||||
// Strip any directory components from the client-supplied filename. It is
|
||||
// later joined onto the temp merge dir (path.join(tempDir, filename)), and
|
||||
// path.join does NOT neutralise `../` — a filename like `../../uploads/
|
||||
// logos/evil.svg` would escape the temp dir and overwrite arbitrary files
|
||||
// (GHSA-pc72-jf53-w28j). basename() collapses it to the leaf name only.
|
||||
const safeFilename = path.basename(String(filename || ''));
|
||||
if (!safeFilename || safeFilename === '.' || safeFilename === '..') {
|
||||
throw new Error('Invalid filename');
|
||||
}
|
||||
|
||||
// Generate unique upload ID
|
||||
const uploadId = crypto.randomUUID();
|
||||
|
||||
@@ -53,7 +43,7 @@ async function initializeUpload(options) {
|
||||
// Store upload metadata
|
||||
const uploadMeta = {
|
||||
uploadId,
|
||||
filename: safeFilename,
|
||||
filename,
|
||||
fileSize,
|
||||
mimeType,
|
||||
eventId,
|
||||
@@ -69,7 +59,7 @@ async function initializeUpload(options) {
|
||||
|
||||
logger.info('Initialized chunked upload', {
|
||||
uploadId,
|
||||
filename: safeFilename,
|
||||
filename,
|
||||
fileSize,
|
||||
expectedChunks,
|
||||
eventId
|
||||
|
||||
@@ -16,7 +16,6 @@ const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
const expenseService = require('./expenseService');
|
||||
const sanitizeHtml = require('sanitize-html');
|
||||
const { isUniqueViolation } = require('../utils/dbErrors');
|
||||
|
||||
const ALLOWED_MIME = ['application/pdf', 'image/jpeg', 'image/png'];
|
||||
@@ -231,36 +230,14 @@ async function roundTripTest({ timeoutMs = 30000, intervalMs = 3000 } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize an inbound HTML body before storing it. Inbound mail is untrusted,
|
||||
// so this strips scripts/handlers/unknown schemes (the viewer ALSO renders it
|
||||
// in a script-less sandboxed iframe — defense in depth). Remote images are kept
|
||||
// (many legit emails embed them) but that is the only tracking-vector allowed.
|
||||
function sanitizeBody(html) {
|
||||
if (!html) return null;
|
||||
try {
|
||||
return sanitizeHtml(html, {
|
||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
|
||||
allowedAttributes: {
|
||||
...sanitizeHtml.defaults.allowedAttributes,
|
||||
img: ['src', 'alt', 'width', 'height'],
|
||||
'*': ['style'],
|
||||
},
|
||||
allowedSchemes: ['http', 'https', 'mailto', 'cid'],
|
||||
});
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/** Poll the mailbox once. Safe to call repeatedly; self-skips when busy/off. */
|
||||
async function pollOnce() {
|
||||
if (polling) return { skipped: 'busy' };
|
||||
if (!(await isEnabled())) return { skipped: 'disabled' };
|
||||
const cfg = await getImapConfig();
|
||||
if (!cfg) return { skipped: 'unconfigured' };
|
||||
|
||||
/**
|
||||
* Poll ONE mailbox once and return the count of newly-processed messages.
|
||||
* `opts.accountKey` tags each received_emails row; `opts.routeToExpenses`
|
||||
* controls whether PDF/image attachments are dropped into the accounting inbox
|
||||
* (true for the primary rechnungen@ mailbox) or only logged with the body
|
||||
* (customer mail, e.g. hello@). The claim/dedup/stale-recovery logic is
|
||||
* identical for every mailbox.
|
||||
*/
|
||||
async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses = true } = {}) {
|
||||
polling = true;
|
||||
const client = makeImapClient(cfg);
|
||||
let processed = 0;
|
||||
try {
|
||||
@@ -327,7 +304,6 @@ async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses
|
||||
try {
|
||||
await db('received_emails').insert({
|
||||
message_id: claimKey,
|
||||
account_key: accountKey,
|
||||
status: 'processing',
|
||||
attachment_count: 0,
|
||||
received_at: new Date(),
|
||||
@@ -339,47 +315,37 @@ async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses
|
||||
throw ce;
|
||||
}
|
||||
|
||||
// Attachment handling. The accounting mailbox drops PDF/image
|
||||
// attachments into the incoming-invoices inbox (isolated so one bad
|
||||
// file can't prevent the audit row). Customer mailboxes only COUNT
|
||||
// attachments — they aren't supplier invoices.
|
||||
// Ingest attachments. Isolate each so one bad file can't prevent the
|
||||
// audit row (the symptom: doc lands in Incoming invoices but the
|
||||
// email never shows under Received).
|
||||
const atts = (parsed.attachments || []).filter((a) => ALLOWED_MIME.includes(a.contentType));
|
||||
let inboundId = null;
|
||||
let count = 0;
|
||||
const attErrors = [];
|
||||
if (routeToExpenses) {
|
||||
const atts = (parsed.attachments || []).filter((a) => ALLOWED_MIME.includes(a.contentType));
|
||||
for (const att of atts) {
|
||||
try {
|
||||
const filePath = await saveAttachment(att);
|
||||
const doc = await expenseService.recordInboundDocument({ source: 'email', filePath, originalFilename: att.filename || 'attachment', mimeType: att.contentType }, null);
|
||||
inboundId = doc.id; count += 1;
|
||||
} catch (ae) {
|
||||
attErrors.push(ae.message);
|
||||
logger.error?.(`emailIntake: attachment "${att.filename}" failed: ${ae.message}`);
|
||||
}
|
||||
for (const att of atts) {
|
||||
try {
|
||||
const filePath = await saveAttachment(att);
|
||||
const doc = await expenseService.recordInboundDocument({ source: 'email', filePath, originalFilename: att.filename || 'attachment', mimeType: att.contentType }, null);
|
||||
inboundId = doc.id; count += 1;
|
||||
} catch (ae) {
|
||||
attErrors.push(ae.message);
|
||||
logger.error?.(`emailIntake: attachment "${att.filename}" failed: ${ae.message}`);
|
||||
}
|
||||
} else {
|
||||
count = (parsed.attachments || []).length;
|
||||
}
|
||||
|
||||
// A malformed Date: header yields an Invalid Date, which throws on a
|
||||
// Postgres timestamp insert — coerce to now.
|
||||
const receivedAt = (parsed.date instanceof Date && !Number.isNaN(parsed.date.getTime())) ? parsed.date : new Date();
|
||||
const status = routeToExpenses
|
||||
? (count > 0 ? 'ingested' : (attErrors.length ? 'error' : 'no_attachment'))
|
||||
: 'received';
|
||||
const status = count > 0 ? 'ingested' : (attErrors.length ? 'error' : 'no_attachment');
|
||||
// Finalise the claimed row — every processed message ends up in the
|
||||
// Received log with its (sanitized) body, even attachment-less ones.
|
||||
// Received tab, even attachment-less ones.
|
||||
await db('received_emails').where({ message_id: claimKey }).update({
|
||||
from_address: ((parsed.from && parsed.from.text) || '').slice(0, 512) || null,
|
||||
to_address: ((parsed.to && parsed.to.text) || '').slice(0, 512) || null,
|
||||
subject: parsed.subject || null,
|
||||
received_at: receivedAt,
|
||||
attachment_count: count,
|
||||
status,
|
||||
inbound_document_id: inboundId,
|
||||
body_html: sanitizeBody(parsed.html || null),
|
||||
body_text: parsed.text || null,
|
||||
error: attErrors.length ? attErrors.join('; ').slice(0, 2000) : null,
|
||||
});
|
||||
await client.messageFlagsAdd(cand.uid, ['\\Seen'], { uid: true });
|
||||
@@ -394,7 +360,7 @@ async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses
|
||||
await db('received_emails').where({ message_id: claimKey })
|
||||
.update({ status: 'error', error: String(e.message).slice(0, 2000) });
|
||||
} else {
|
||||
await db('received_emails').insert({ message_id: `err-${cand.uid}-${Date.now()}`, account_key: accountKey, status: 'error', error: e.message, attachment_count: 0, received_at: new Date(), created_at: new Date() });
|
||||
await db('received_emails').insert({ message_id: `err-${cand.uid}-${Date.now()}`, status: 'error', error: e.message, attachment_count: 0, received_at: new Date(), created_at: new Date() });
|
||||
}
|
||||
} catch (ie) {
|
||||
logger.error?.(`emailIntake: could not even write the error row (received_emails insert failing): ${ie.message}`);
|
||||
@@ -407,55 +373,11 @@ async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses
|
||||
/* eslint-enable no-await-in-loop */
|
||||
await client.logout();
|
||||
} catch (e) {
|
||||
logger.error?.(`emailIntake: poll failed (${accountKey}): ${e.message}`);
|
||||
logger.error?.(`emailIntake: poll failed: ${e.message}`);
|
||||
try { await client.close(); } catch (_e) { /* ignore */ }
|
||||
}
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll ALL configured inbound mailboxes once: the primary accounting IMAP
|
||||
* (email_configs) plus every enabled row in mail_accounts (e.g. hello@).
|
||||
* Safe to call repeatedly; self-skips when busy/off.
|
||||
*/
|
||||
async function pollOnce() {
|
||||
if (polling) return { skipped: 'busy' };
|
||||
if (!(await isEnabled())) return { skipped: 'disabled' };
|
||||
polling = true;
|
||||
let processed = 0;
|
||||
let anyConfigured = false;
|
||||
try {
|
||||
// 1) Primary accounting mailbox — routes attachments to the invoices inbox.
|
||||
const acctCfg = await getImapConfig();
|
||||
if (acctCfg) {
|
||||
anyConfigured = true;
|
||||
processed += await pollAccountOnce(acctCfg, { accountKey: 'accounting', routeToExpenses: true });
|
||||
}
|
||||
// 2) Additional mailboxes (customers/hello@) — body captured, no expense
|
||||
// routing. Guarded so a pre-migration DB simply polls the accounting box.
|
||||
let extras = [];
|
||||
try {
|
||||
if (await db.schema.hasTable('mail_accounts')) {
|
||||
extras = await db('mail_accounts').where({ enabled: true });
|
||||
}
|
||||
} catch (_) { extras = []; }
|
||||
for (const a of extras) {
|
||||
if (!a.imap_host || !a.imap_user) continue;
|
||||
anyConfigured = true;
|
||||
const cfg = {
|
||||
host: a.imap_host,
|
||||
port: a.imap_port || 993,
|
||||
secure: a.imap_secure !== false && a.imap_secure !== 0,
|
||||
auth: { user: a.imap_user, pass: a.imap_pass || '' },
|
||||
folder: a.imap_folder || 'INBOX',
|
||||
};
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
processed += await pollAccountOnce(cfg, { accountKey: a.account_key, routeToExpenses: false });
|
||||
}
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
if (!anyConfigured) return { skipped: 'unconfigured' };
|
||||
return { processed };
|
||||
}
|
||||
|
||||
|
||||
@@ -775,62 +775,6 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a fully-composed email (subject + HTML the admin already edited in the
|
||||
* Messages composer) WITHOUT a template. Used for replies + human-sent document
|
||||
* messages. Uses the configured SMTP identity + from address. Returns
|
||||
* { messageId, html } so the caller can persist rendered_html for the record.
|
||||
*/
|
||||
async function sendRawEmail({ to, cc, subject, html, text, attachments, accountKey } = {}) {
|
||||
let tx = null;
|
||||
let fromEmail = null;
|
||||
let fromName = null;
|
||||
|
||||
// Prefer a per-account outgoing identity (e.g. hello@) when the mail account
|
||||
// has its own SMTP config, so customer replies send from that address instead
|
||||
// of the global no-reply@. Falls back to the global SMTP transport.
|
||||
if (accountKey) {
|
||||
const acct = await db('mail_accounts').where({ account_key: accountKey }).first();
|
||||
if (acct && acct.smtp_host && (acct.smtp_user || acct.from_email)) {
|
||||
const nodemailer = require('nodemailer');
|
||||
tx = nodemailer.createTransport({
|
||||
host: acct.smtp_host,
|
||||
port: parseInt(acct.smtp_port, 10) || 587,
|
||||
secure: acct.smtp_secure === true || acct.smtp_secure === 1,
|
||||
auth: acct.smtp_user && acct.smtp_pass ? { user: acct.smtp_user, pass: acct.smtp_pass } : undefined,
|
||||
tls: { rejectUnauthorized: true },
|
||||
});
|
||||
fromEmail = acct.from_email || acct.smtp_user;
|
||||
fromName = acct.from_name || '';
|
||||
}
|
||||
}
|
||||
if (!tx) {
|
||||
tx = await initializeTransporter();
|
||||
if (!tx) throw new Error('Email service not configured');
|
||||
const config = await db('email_configs').first();
|
||||
if (!config || !config.from_email) throw new Error('Email service not configured');
|
||||
fromEmail = config.from_email;
|
||||
fromName = config.from_name;
|
||||
}
|
||||
|
||||
const ccList = Array.isArray(cc) ? cc.filter(Boolean) : (cc ? [cc] : undefined);
|
||||
const atts = Array.isArray(attachments)
|
||||
? attachments.filter((a) => a && (a.contentPath || a.path || a.content))
|
||||
.map((a) => ({ filename: a.filename, path: a.contentPath || a.path, content: a.content, contentType: a.contentType }))
|
||||
: undefined;
|
||||
const info = await tx.sendMail({
|
||||
from: `${fromName || 'picpeak'} <${fromEmail}>`,
|
||||
to,
|
||||
cc: ccList,
|
||||
subject,
|
||||
html,
|
||||
text: text || htmlToText(html),
|
||||
attachments: atts,
|
||||
});
|
||||
logger.info(`Manual email sent: ${info.messageId}`);
|
||||
return { messageId: info.messageId, html };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a queued email's HTML WITHOUT sending it. Used by the Project
|
||||
* Overview cockpit to preview emails that predate the rendered_html column
|
||||
@@ -1164,7 +1108,6 @@ module.exports = {
|
||||
initializeTransporter,
|
||||
startEmailQueueProcessor,
|
||||
sendTemplateEmail,
|
||||
sendRawEmail,
|
||||
renderQueuedEmail,
|
||||
processEmailQueue,
|
||||
queueEmail,
|
||||
|
||||
@@ -18,7 +18,6 @@ const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const StreamZip = require('node-stream-zip');
|
||||
const { assertZipEntriesWithin } = require('../utils/safePath');
|
||||
const { db } = require('../database/db');
|
||||
const knexConfig = require('../../knexfile');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
@@ -81,85 +80,25 @@ function parseNdjson(filePath) {
|
||||
}
|
||||
|
||||
// Re-insert the operator's account inside the restore transaction so they keep
|
||||
// working credentials after the wipe.
|
||||
//
|
||||
// The operator's login + credentials + MFA must be restored, not just the
|
||||
// password. A crafted backup can carry a row with the operator's email whose
|
||||
// two_factor_* fields are attacker-chosen — leaving those in place would let
|
||||
// the backup strip or hijack the operator's MFA, or (cross-instance) pin a TOTP
|
||||
// secret encrypted with the source instance's key the operator can never
|
||||
// satisfy. These columns are scalar/text (recovery codes are a JSON string in a
|
||||
// TEXT column), so writing them needs no special json handling. Relationship/
|
||||
// audit FKs (role_id, created_by) are deliberately NOT forced from the snapshot
|
||||
// — see the update branch below.
|
||||
//
|
||||
// admin_users has UNIQUE constraints on BOTH email and username, and a restored
|
||||
// backup can collide with the operator on either — possibly on two DIFFERENT
|
||||
// rows (one shares the email, another shares the default `admin` username). We
|
||||
// reconcile WITHOUT deleting any restored row: deleting would fire ON DELETE
|
||||
// actions (SQLite) or dangle references such as events.created_by (Postgres,
|
||||
// where replica mode suppresses cascades). Instead:
|
||||
// - if a row already has the operator's email, overwrite it in place (its id
|
||||
// is preserved, so every FK pointing at the operator stays valid);
|
||||
// - if a DIFFERENT row holds the operator's username, rename that row (id
|
||||
// preserved, its own FKs stay valid) to free the username;
|
||||
// - only when no row has the operator's email do we insert a fresh row.
|
||||
// working credentials. If the backup already loaded an admin with the same
|
||||
// email, overwrite that row's credentials with the current account's (current
|
||||
// creds win); otherwise insert the snapshot with a fresh id.
|
||||
async function reinjectCurrentAdmin(trx, currentAdmin) {
|
||||
if (!currentAdmin) return;
|
||||
|
||||
const emailMatch = await trx('admin_users')
|
||||
.whereRaw('lower(email) = lower(?)', [currentAdmin.email])
|
||||
.first();
|
||||
|
||||
// Free the operator's username if a different row holds it (rename, not delete).
|
||||
const usernameHolder = await trx('admin_users')
|
||||
.whereRaw('lower(username) = lower(?)', [currentAdmin.username])
|
||||
.first();
|
||||
if (usernameHolder && (!emailMatch || usernameHolder.id !== emailMatch.id)) {
|
||||
await trx('admin_users')
|
||||
.where({ id: usernameHolder.id })
|
||||
.update({ username: `${usernameHolder.username}__restored_${usernameHolder.id}` });
|
||||
}
|
||||
|
||||
if (emailMatch) {
|
||||
// Update in place — keeps emailMatch.id so restored FKs to the operator
|
||||
// hold. Write only the AUTH-critical columns (login identity + credentials
|
||||
// + MFA), never the relationship/audit FKs (role_id → roles, created_by →
|
||||
// admin_users). Forcing the operator's pre-restore role_id/created_by here
|
||||
// could reference rows absent from a cross-instance backup and dangle the
|
||||
// FK (SQLite rolls back at commit); the row already carries the backup's
|
||||
// own valid values for those. This still closes the MFA-hijack gap — a
|
||||
// crafted backup can't strip or replace the operator's second factor.
|
||||
const authUpdate = {};
|
||||
for (const field of PRESERVED_AUTH_FIELDS) {
|
||||
if (field in currentAdmin) authUpdate[field] = currentAdmin[field];
|
||||
}
|
||||
await trx('admin_users').where({ id: emailMatch.id }).update(authUpdate);
|
||||
const existing = await trx('admin_users').whereRaw('lower(email) = lower(?)', [currentAdmin.email]).first();
|
||||
if (existing) {
|
||||
await trx('admin_users').where({ id: existing.id }).update({
|
||||
password_hash: currentAdmin.password_hash,
|
||||
is_active: currentAdmin.is_active,
|
||||
must_change_password: currentAdmin.must_change_password,
|
||||
});
|
||||
} else {
|
||||
// The operator's email isn't in the backup, so nothing restored references
|
||||
// their id — a fresh row can't dangle a reference TO the operator. Null the
|
||||
// self-referential created_by (its target admin may be absent from this
|
||||
// backup; ON DELETE SET NULL makes null the correct "unknown inviter"
|
||||
// value) so the insert itself can't dangle. Use an explicit max(id)+1
|
||||
// rather than the identity sequence, which batchInsert left unadvanced on
|
||||
// Postgres (a sequence-based insert could collide with a restored id).
|
||||
const snapshot = { ...currentAdmin };
|
||||
delete snapshot.id;
|
||||
if ('created_by' in snapshot) snapshot.created_by = null;
|
||||
const maxRow = await trx('admin_users').max({ m: 'id' }).first();
|
||||
snapshot.id = (Number(maxRow && maxRow.m) || 0) + 1;
|
||||
await trx('admin_users').insert(snapshot);
|
||||
const row = { ...currentAdmin };
|
||||
delete row.id; // let the engine assign a fresh id to avoid collision
|
||||
await trx('admin_users').insert(row);
|
||||
}
|
||||
}
|
||||
|
||||
// AUTH-critical admin_users columns preserved when overwriting a restored row
|
||||
// that shares the operator's email. Deliberately excludes relationship/audit
|
||||
// FKs (role_id, created_by) — see reinjectCurrentAdmin for why.
|
||||
const PRESERVED_AUTH_FIELDS = [
|
||||
'username', 'email', 'password_hash', 'is_active', 'must_change_password',
|
||||
'two_factor_enabled', 'two_factor_secret', 'two_factor_recovery_codes', 'two_factor_enrolled_at',
|
||||
];
|
||||
|
||||
// The json/jsonb columns of a table (Postgres only). The pg driver returns
|
||||
// jsonb as parsed JS values, so on re-insert they must be serialised back to
|
||||
// valid JSON text — otherwise a scalar like the string "PicPeak" is sent
|
||||
@@ -293,10 +232,6 @@ async function importFromPicpeak({ picpeakPath, currentAdminId }) {
|
||||
try {
|
||||
const zip = new StreamZip.async({ file: picpeakPath });
|
||||
try {
|
||||
// Reject ZIP-slip entries before extracting — a crafted .picpeak could
|
||||
// otherwise write outside the staging dir via `../` entry names
|
||||
// (same class as GHSA-jfhw-fj23-fx6x).
|
||||
assertZipEntriesWithin(Object.values(await zip.entries()), staging);
|
||||
await zip.extract(null, staging);
|
||||
} finally {
|
||||
await zip.close();
|
||||
@@ -333,5 +268,4 @@ module.exports = {
|
||||
importFromPicpeak,
|
||||
readManifestFromZip,
|
||||
validateManifest,
|
||||
reinjectCurrentAdmin,
|
||||
};
|
||||
|
||||
@@ -46,18 +46,11 @@ function outEdge(edges, fromNode, handle) {
|
||||
|
||||
function computeWakeAt(config = {}, vars = {}) {
|
||||
const cfg = config || {};
|
||||
if (cfg.untilVar && vars[cfg.untilVar]) return new Date(vars[cfg.untilVar]).toISOString();
|
||||
const ms = (Number(cfg.delayDays || 0) * 86400000)
|
||||
+ (Number(cfg.delayHours || 0) * 3600000)
|
||||
+ (Number(cfg.delayMinutes || 0) * 60000);
|
||||
// Anchor to a context var when given (e.g. dueDate), plus any delay offset —
|
||||
// so `{ untilVar: 'dueDate', delayDays: 7 }` means "due date + 7 days"
|
||||
// (absolute), and an already-past anchor resumes immediately. Backward
|
||||
// compatible: untilVar-only → the var; delay-only → now + delay. Behaviour
|
||||
// change for the both-fields case (untilVar + delay): previously the delay was
|
||||
// ignored and only the var returned; now they add (this is the intended
|
||||
// waitGrace semantics — no seeded node relied on the old both-fields path).
|
||||
const base = (cfg.untilVar && vars[cfg.untilVar]) ? new Date(vars[cfg.untilVar]) : new Date();
|
||||
return new Date(base.getTime() + ms).toISOString();
|
||||
return new Date(Date.now() + ms).toISOString();
|
||||
}
|
||||
|
||||
function gateTimeout(config = {}) {
|
||||
@@ -428,51 +421,6 @@ async function isBuiltinFlowActive(builtinKey) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enroll every open, unpaid invoice into the dunning flow by emitting
|
||||
* `invoice.sent` for it — called when the dunning built-in is turned ON so it
|
||||
* starts chasing invoices that were already sent, not only new ones (#750).
|
||||
* Idempotent: emitWorkflowEvent's per-(flow, entity) dedup means at most one
|
||||
* run per invoice, so re-enabling is safe. Paired with the due-date-anchored
|
||||
* grace wait, already-overdue invoices dun on their real timeline immediately.
|
||||
*
|
||||
* Scoped to `targetWorkflowId` (the dunning flow being enabled) so the backfill
|
||||
* only enrolls invoices into dunning — never into unrelated custom `invoice.sent`
|
||||
* flows an admin may have built, which would fire their actions for every
|
||||
* historical invoice.
|
||||
*/
|
||||
async function backfillDunningRuns(targetWorkflowId) {
|
||||
let enrolled = 0;
|
||||
try {
|
||||
if (!(await db.schema.hasTable('invoices'))) return 0;
|
||||
const invoices = await db('invoices')
|
||||
.whereIn('status', ['sent', 'overdue'])
|
||||
.whereNotNull('due_date')
|
||||
.whereRaw('COALESCE(paid_amount_minor, 0) < total_amount_minor');
|
||||
for (const inv of invoices) {
|
||||
const ids = await emitWorkflowEvent('invoice.sent', {
|
||||
entityType: 'invoice',
|
||||
entityId: inv.id,
|
||||
targetWorkflowId,
|
||||
payload: {
|
||||
invoiceId: inv.id,
|
||||
invoiceNumber: inv.invoice_number,
|
||||
eventId: inv.event_id || null,
|
||||
customerAccountId: inv.customer_account_id,
|
||||
dueDate: inv.due_date,
|
||||
issueDate: inv.issue_date,
|
||||
totalMinor: inv.total_amount_minor,
|
||||
currency: inv.currency,
|
||||
},
|
||||
});
|
||||
if (ids && ids.length) enrolled += 1;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('[workflow] dunning backfill failed', { error: e.message });
|
||||
}
|
||||
return enrolled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit `event.date_approaching` for events entering an enabled flow's lead
|
||||
* window. This is the trigger source for the pre-event reminder built-in, so it
|
||||
@@ -597,7 +545,6 @@ async function testRun(workflowId, { entityType = null, entityId = null, payload
|
||||
module.exports = {
|
||||
emitWorkflowEvent,
|
||||
isBuiltinFlowActive,
|
||||
backfillDunningRuns,
|
||||
runDueWaits,
|
||||
emitDueEventReminders,
|
||||
recoverStaleRuns,
|
||||
|
||||
@@ -118,41 +118,7 @@ function assertContractPdfPath(filePath) {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP-slip guard. `node-stream-zip`'s `extract(null, root)` writes each entry
|
||||
* to `path.join(root, entry.name)` without neutralising `../` — a crafted
|
||||
* archive with an entry named `../../uploads/logos/evil.svg` escapes `root`
|
||||
* and overwrites arbitrary files (GHSA-jfhw-fj23-fx6x). Call this with the
|
||||
* entry list BEFORE extract() to reject any entry that resolves outside the
|
||||
* target directory.
|
||||
*
|
||||
* Purely lexical (path.resolve, no realpath) because the extraction target
|
||||
* does not exist on disk yet. Absolute entry names (`/etc/passwd`) resolve
|
||||
* away from `root` and are caught too. Throws AppError 400 on the first
|
||||
* offending entry so the whole archive is refused.
|
||||
*
|
||||
* @param {Array<{name?: string}>} entries node-stream-zip entry objects
|
||||
* @param {string} extractRoot directory extract() will write into
|
||||
*/
|
||||
function assertZipEntriesWithin(entries, extractRoot) {
|
||||
const rootResolved = path.resolve(extractRoot);
|
||||
const prefix = rootResolved.endsWith(path.sep) ? rootResolved : rootResolved + path.sep;
|
||||
for (const entry of entries || []) {
|
||||
const name = entry && entry.name;
|
||||
if (!name) continue;
|
||||
const target = path.resolve(rootResolved, name);
|
||||
if (target !== rootResolved && !target.startsWith(prefix)) {
|
||||
throw new AppError(
|
||||
`Archive contains an entry that escapes the extraction directory: ${name}`,
|
||||
400,
|
||||
'ZIP_SLIP'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertPathInside,
|
||||
assertContractPdfPath,
|
||||
assertZipEntriesWithin,
|
||||
};
|
||||
|
||||
+7
-17
@@ -29,24 +29,14 @@ COPY . .
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage (nginx stable 1.30 on Alpine 3.24). The 1.28 base is a
|
||||
# dead end for the nginx HTTP/2 + rewrite/charset CVEs (CVE-2026-42055 /
|
||||
# -49975 / -9256 / -48142): nginx.org's nginx-module-* packages pin the exact
|
||||
# nginx version, so `apk upgrade` can never pull Alpine's patched 1.28.3-r4 —
|
||||
# nginx fixes have to come via the base image tag, not apk.
|
||||
FROM nginx:1.30-alpine
|
||||
# Production stage (Alpine 3.23 with OpenSSL 3.5.5, patched libexpat)
|
||||
FROM nginx:1.28-alpine
|
||||
|
||||
# Redeclare CACHEBUST — ARGs don't cross stage boundaries, so the builder
|
||||
# stage's declaration never reached this stage. Consuming it in the RUN below
|
||||
# busts that layer's cache every CI run (CACHEBUST=github.run_number), so the
|
||||
# image always picks up current Alpine security updates. Without this, the
|
||||
# upgrade layer was cached indefinitely and builds kept shipping curl 8.19.0 /
|
||||
# c-ares 1.34.6 for weeks after fixed packages landed in the Alpine repo.
|
||||
ARG CACHEBUST=1
|
||||
|
||||
# Upgrade all Alpine packages for security fixes (nginx itself is version-
|
||||
# pinned by its module packages — see the FROM comment above).
|
||||
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
|
||||
# Upgrade all Alpine packages for security fixes. The explicit nginx upgrade
|
||||
# closes the HTTP/2 + rewrite/charset CVEs (CVE-2026-42055 / -49975 / -9256 /
|
||||
# -48142, fixed in nginx 1.28.3-r4) and busts any cached layer still carrying
|
||||
# the vulnerable r1 build.
|
||||
RUN apk upgrade --no-cache && apk add --no-cache --upgrade nginx
|
||||
|
||||
# Install runtime dependencies. `gettext` provides envsubst, used by
|
||||
# docker-entrypoint.sh for the BRAND_TITLE / BRAND_DESCRIPTION runtime
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "3.45.2",
|
||||
"version": "3.82.4-beta.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -42,7 +42,6 @@ import { HoursLoggingPage } from './pages/admin/clients/HoursLoggingPage';
|
||||
// (carved into its own chunk in vite.config.ts) doesn't ship with the
|
||||
// main app. Only pages that visit /admin/clients/calendar fetch it.
|
||||
const CalendarPage = lazy(() => import('./pages/admin/clients/CalendarPage').then((m) => ({ default: m.CalendarPage })));
|
||||
const MessagesPage = lazy(() => import('./pages/admin/messages/MessagesPage').then((m) => ({ default: m.MessagesPage })));
|
||||
import { QuoteResponsePage } from './pages/public/QuoteResponsePage';
|
||||
import { ContractResponsePage } from './pages/public/ContractResponsePage';
|
||||
import { ProjectsListPage } from './pages/admin/projects/ProjectsListPage';
|
||||
@@ -242,13 +241,6 @@ function App() {
|
||||
<Route element={<RequireFeature flag="userManagement" />}>
|
||||
<Route path="users" element={<UserManagementPage />} />
|
||||
</Route>
|
||||
<Route element={<RequireFeature flag="messaging" />}>
|
||||
<Route path="messages" element={
|
||||
<Suspense fallback={<Loading />}>
|
||||
<MessagesPage />
|
||||
</Suspense>
|
||||
} />
|
||||
</Route>
|
||||
{/* Clients section (#354 follow-up). Parent route
|
||||
gated by the top-level `clients` flag — when off
|
||||
the sidebar entry is hidden and every /admin/clients/*
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Users,
|
||||
Briefcase,
|
||||
Landmark,
|
||||
Mail,
|
||||
Workflow,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
@@ -65,7 +64,6 @@ const navigation: NavItem[] = [
|
||||
{ nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard, permission: false },
|
||||
{ nameKey: 'navigation.events', href: '/admin/events', icon: Calendar, permission: 'events.view' },
|
||||
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' },
|
||||
{ nameKey: 'navigation.messages', href: '/admin/messages', icon: Mail, permission: 'email.view', featureFlag: 'messaging' },
|
||||
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view', featureFlag: 'analytics' },
|
||||
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
|
||||
{ nameKey: 'navigation.systemHealth', href: '/admin/system-health', icon: Activity, permission: 'settings.view' },
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* Customer mailbox (hello@) configuration — a second inbound IMAP box beyond
|
||||
* the accounting rechnungen@ one, stored in `mail_accounts` under the fixed
|
||||
* account_key 'customers'. Its mail feeds Messages → Customers ▸ Inbox (body
|
||||
* captured, attachments NOT routed to accounting). Shown when the `messaging`
|
||||
* feature flag is on. Styled to match the Incoming Mail card.
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Save, Server, User, Lock, Eye, EyeOff, PlugZap, Inbox } from 'lucide-react';
|
||||
import { Button, Card, Input, Loading } from '../common';
|
||||
import { emailService, type MailAccount } from '../../services/email.service';
|
||||
import { useMutationWithToast, useModal } from '../../hooks';
|
||||
|
||||
const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
|
||||
const selectCls = 'w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark';
|
||||
|
||||
const ACCOUNT_KEY = 'customers';
|
||||
|
||||
export const CustomerMailboxCard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useQuery({ queryKey: ['mail-accounts'], queryFn: () => emailService.listMailAccounts() });
|
||||
const [cfg, setCfg] = useState<MailAccount>({ account_key: ACCOUNT_KEY, imap_host: '', imap_port: 993, imap_secure: true, imap_user: '', imap_pass: '', imap_folder: 'INBOX', enabled: false });
|
||||
const passwordVisibility = useModal();
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const row = data.find((a) => a.account_key === ACCOUNT_KEY);
|
||||
if (row) setCfg({ ...row, imap_pass: row.imap_pass || '' });
|
||||
}, [data]);
|
||||
|
||||
const set = (k: keyof MailAccount, v: any) => setCfg((c) => ({ ...c, [k]: v }));
|
||||
|
||||
const save = useMutationWithToast({
|
||||
mutationFn: () => {
|
||||
if (!cfg.imap_host || !cfg.imap_port || !cfg.imap_user) {
|
||||
return Promise.reject(new Error(t('email.customerMailbox.requiredFields', 'Host, port and username are required.')));
|
||||
}
|
||||
return emailService.saveMailAccount({ ...cfg, account_key: ACCOUNT_KEY, label: 'Customers' });
|
||||
},
|
||||
successMessage: t('email.customerMailbox.savedToast', 'Customer mailbox saved.'),
|
||||
invalidateKeys: [['mail-accounts']],
|
||||
errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
|
||||
});
|
||||
|
||||
const test = useMutationWithToast({
|
||||
mutationFn: () => emailService.testMailAccount({ ...cfg, account_key: ACCOUNT_KEY }),
|
||||
successMessage: (r) => t('email.customerMailbox.testOk', 'Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.', { folder: r.folder, messages: r.messages, unseen: r.unseen }),
|
||||
errorMessage: (e: any) => e?.response?.data?.error || e.message || t('email.customerMailbox.testFailed', 'Connection failed.'),
|
||||
});
|
||||
|
||||
if (isLoading) return <Loading />;
|
||||
|
||||
return (
|
||||
<Card padding="md" className="mt-6">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
|
||||
<Inbox className="w-5 h-5 text-neutral-400" />
|
||||
{t('email.customerMailbox.title', 'Customer mailbox (hello@)')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t('email.customerMailbox.subtitle', 'A second inbound mailbox for customer conversations. Its mail appears under Messages → Customers; attachments are not routed to Accounting.')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
|
||||
<input type="checkbox" checked={!!cfg.enabled} onChange={(e) => set('enabled', e.target.checked)} />
|
||||
{t('email.customerMailbox.enabled', 'Poll this mailbox every minute')}
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.host', 'IMAP Host')} <span className="text-red-500">*</span></label>
|
||||
<Input type="text" value={cfg.imap_host || ''} onChange={(e) => set('imap_host', e.target.value)} placeholder="imap.example.com" leftIcon={<Server className="w-5 h-5 text-neutral-400" />} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.port', 'Port')} <span className="text-red-500">*</span></label>
|
||||
<Input type="number" value={cfg.imap_port ?? 993} onChange={(e) => set('imap_port', parseInt(e.target.value, 10) || 0)} placeholder="993" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.security', 'Security')}</label>
|
||||
<select className={selectCls} value={cfg.imap_secure ? 'ssl' : 'plain'} onChange={(e) => set('imap_secure', e.target.value === 'ssl')}>
|
||||
<option value="ssl">{t('email.incoming.ssl', 'SSL/TLS')}</option>
|
||||
<option value="plain">{t('email.incoming.plain', 'None / STARTTLS')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.user', 'Username')} <span className="text-red-500">*</span></label>
|
||||
<Input type="text" value={cfg.imap_user || ''} onChange={(e) => set('imap_user', e.target.value)} autoComplete="off" placeholder="hello@yourdomain.com" leftIcon={<User className="w-5 h-5 text-neutral-400" />} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.pass', 'Password')}</label>
|
||||
<div className="relative">
|
||||
<Input type={passwordVisibility.isOpen ? 'text' : 'password'} value={cfg.imap_pass || ''} onChange={(e) => set('imap_pass', e.target.value)} autoComplete="new-password" placeholder={t('email.enterPassword', 'Enter password')} leftIcon={<Lock className="w-5 h-5 text-neutral-400" />} />
|
||||
<button type="button" onClick={passwordVisibility.toggle} className="absolute right-3 top-3 text-neutral-400 hover:text-neutral-600">
|
||||
{passwordVisibility.isOpen ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.folder', 'Folder')}</label>
|
||||
<Input type="text" value={cfg.imap_folder || 'INBOX'} onChange={(e) => set('imap_folder', e.target.value)} placeholder="INBOX" />
|
||||
</div>
|
||||
|
||||
<div className="pt-4 mt-1 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<div className="text-sm font-semibold text-neutral-800 dark:text-neutral-200">
|
||||
{t('email.customerMailbox.outgoing', 'Outgoing (SMTP)')}
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5 mb-3">
|
||||
{t('email.customerMailbox.outgoingHint', 'Replies from this mailbox send from here. Leave blank to fall back to the global outgoing address.')}
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.customerMailbox.fromEmail', 'From address')}</label>
|
||||
<Input type="text" value={cfg.from_email || ''} onChange={(e) => set('from_email', e.target.value)} placeholder="hello@yourdomain.com" leftIcon={<User className="w-5 h-5 text-neutral-400" />} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.customerMailbox.smtpHost', 'SMTP Host')}</label>
|
||||
<Input type="text" value={cfg.smtp_host || ''} onChange={(e) => set('smtp_host', e.target.value)} placeholder="smtp.example.com" leftIcon={<Server className="w-5 h-5 text-neutral-400" />} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.port', 'Port')}</label>
|
||||
<Input type="number" value={cfg.smtp_port ?? 587} onChange={(e) => set('smtp_port', parseInt(e.target.value, 10) || 0)} placeholder="587" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.security', 'Security')}</label>
|
||||
<select className={selectCls} value={cfg.smtp_secure ? 'ssl' : 'starttls'} onChange={(e) => set('smtp_secure', e.target.value === 'ssl')}>
|
||||
<option value="ssl">{t('email.customerMailbox.smtpSsl', 'SSL (465)')}</option>
|
||||
<option value="starttls">{t('email.customerMailbox.smtpStarttls', 'STARTTLS (587)')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.customerMailbox.smtpUser', 'SMTP Username')}</label>
|
||||
<Input type="text" value={cfg.smtp_user || ''} onChange={(e) => set('smtp_user', e.target.value)} autoComplete="off" placeholder="hello@yourdomain.com" leftIcon={<User className="w-5 h-5 text-neutral-400" />} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.customerMailbox.smtpPass', 'SMTP Password')}</label>
|
||||
<Input type={passwordVisibility.isOpen ? 'text' : 'password'} value={cfg.smtp_pass || ''} onChange={(e) => set('smtp_pass', e.target.value)} autoComplete="new-password" placeholder={t('email.enterPassword', 'Enter password')} leftIcon={<Lock className="w-5 h-5 text-neutral-400" />} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => test.mutate()} isLoading={test.isPending} disabled={!cfg.imap_host || !cfg.imap_user} leftIcon={<PlugZap className="w-5 h-5" />} className="whitespace-nowrap">
|
||||
{t('email.incoming.test', 'Test connection')}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => save.mutate()} isLoading={save.isPending} leftIcon={<Save className="w-5 h-5" />} className="flex-1 min-w-[12rem]">
|
||||
{t('email.customerMailbox.save', 'Save Customer Mailbox')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerMailboxCard;
|
||||
@@ -16,7 +16,6 @@ interface RestoreResult {
|
||||
tables: number;
|
||||
filesRestored: number;
|
||||
usesExternalMedia: boolean;
|
||||
sessionInvalidated?: boolean;
|
||||
}
|
||||
|
||||
// ── Download half (Dashboard) ────────────────────────────────────────────────
|
||||
@@ -115,13 +114,6 @@ export const PicpeakRestoreCard: React.FC = () => {
|
||||
setResult(res.data);
|
||||
setPendingFile(null);
|
||||
toast.success(t('backup.picpeak.restoreDone', 'Backup restored.'));
|
||||
// The restore rewrote admin_users and the backend revoked our session
|
||||
// (ids may have shifted). Send the operator to a fresh login rather than
|
||||
// letting the now-stale token resolve to a different restored account.
|
||||
if (res.data?.sessionInvalidated) {
|
||||
toast.success(t('backup.picpeak.reloginRequired', 'Restore complete — please sign in again.'));
|
||||
setTimeout(() => { window.location.href = '/admin/login'; }, 1500);
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = e.response?.data?.error || t('backup.picpeak.restoreFailed', 'Restore failed.');
|
||||
toast.error(msg);
|
||||
|
||||
@@ -235,13 +235,15 @@ export const FeaturesTab: React.FC = () => {
|
||||
title={t('settings.features.messaging.title', 'Messaging')}
|
||||
description={t(
|
||||
'settings.features.messaging.description',
|
||||
'A unified Messages area: your sent + automated mail, the accounting inbox, and a customer mailbox (hello@) in one place — with reply and create-from-template composing. Configure the customer mailbox under Settings → Email; incoming mailboxes need the Incoming mail toggle too.',
|
||||
'In-app threads with guests, attached to a gallery. Email is genuinely fine for most teams — this is for studios that want everything in one place. Coming soon.',
|
||||
)}
|
||||
status="new"
|
||||
statusLabel={statusLabel('new')}
|
||||
status="roadmap"
|
||||
statusLabel={statusLabel('roadmap')}
|
||||
sidebarLabel={t('settings.features.messaging.sidebar', 'Messages')}
|
||||
enabled={staged.messaging}
|
||||
onToggle={(next) => setFlag('messaging', next)}
|
||||
onToggle={() => { /* locked */ }}
|
||||
disabled
|
||||
lockedReason={NOT_YET_AVAILABLE}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -197,7 +197,6 @@
|
||||
"navigation": {
|
||||
"dashboard": "Dashboard",
|
||||
"events": "Veranstaltungen",
|
||||
"messages": "Nachrichten",
|
||||
"settings": "Einstellungen",
|
||||
"systemHealth": "Systemzustand",
|
||||
"archives": "Archive",
|
||||
@@ -3136,24 +3135,6 @@
|
||||
"backup": "Backup & Wiederherstellung",
|
||||
"system": "System-Updates",
|
||||
"other": "Sonstige"
|
||||
},
|
||||
"customerMailbox": {
|
||||
"title": "Kunden-Postfach (hello@)",
|
||||
"subtitle": "Ein zweites Eingangspostfach für Kundenkommunikation. Die E-Mails erscheinen unter Nachrichten → Kunden; Anhänge werden nicht an die Buchhaltung weitergeleitet.",
|
||||
"enabled": "Dieses Postfach jede Minute abrufen",
|
||||
"outgoing": "Ausgang (SMTP)",
|
||||
"outgoingHint": "Antworten aus diesem Postfach werden von hier gesendet. Leer lassen, um die globale Absenderadresse zu verwenden.",
|
||||
"fromEmail": "Absenderadresse",
|
||||
"smtpHost": "SMTP-Host",
|
||||
"smtpUser": "SMTP-Benutzername",
|
||||
"smtpPass": "SMTP-Passwort",
|
||||
"smtpSsl": "SSL (465)",
|
||||
"smtpStarttls": "STARTTLS (587)",
|
||||
"save": "Kunden-Postfach speichern",
|
||||
"savedToast": "Kunden-Postfach gespeichert.",
|
||||
"requiredFields": "Host, Port und Benutzername sind erforderlich.",
|
||||
"testOk": "Verbunden mit {{folder}} — {{messages}} Nachrichten, {{unseen}} ungelesen.",
|
||||
"testFailed": "Verbindung fehlgeschlagen."
|
||||
}
|
||||
},
|
||||
"cms": {
|
||||
@@ -5552,88 +5533,5 @@
|
||||
"titlePlaceholder": "z. B. Hochzeitsvertrag Doe / Müller",
|
||||
"validUntil": "Unterzeichnen bis (optional)"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"title": "Nachrichten",
|
||||
"subtitle": "Gesendete, automatische und eingehende E-Mails — an einem Ort.",
|
||||
"sync": "Abrufen",
|
||||
"newMessage": "Neue Nachricht",
|
||||
"searchPlaceholder": "In diesem Ordner suchen…",
|
||||
"account": {
|
||||
"all": "Alle E-Mails",
|
||||
"customers": "Kunden",
|
||||
"accounting": "Buchhaltung",
|
||||
"automated": "Automatisch"
|
||||
},
|
||||
"folder": {
|
||||
"inbox": "Posteingang",
|
||||
"sent": "Gesendet",
|
||||
"archived": "Archiviert",
|
||||
"deleted": "Gelöscht"
|
||||
},
|
||||
"unified": "Konten übergreifend",
|
||||
"systemGenerated": "Systemgeneriert",
|
||||
"acrossAccounts": "Über alle Konten",
|
||||
"selectPrompt": "Nachricht zum Lesen auswählen",
|
||||
"noMessages": "Keine Nachrichten",
|
||||
"noSearchResults": "Keine Treffer",
|
||||
"noSubject": "(kein Betreff)",
|
||||
"from": "von",
|
||||
"to": "An",
|
||||
"reply": "Antworten",
|
||||
"replyAll": "Allen antworten",
|
||||
"forward": "Weiterleiten",
|
||||
"archive": "Archivieren",
|
||||
"delete": "Löschen",
|
||||
"deleteForever": "Endgültig löschen",
|
||||
"restore": "Wiederherstellen",
|
||||
"bookExpense": "Als Ausgabe buchen",
|
||||
"rebill": "An Kunden weiterverrechnen",
|
||||
"createQuote": "Angebot",
|
||||
"createContract": "Vertrag",
|
||||
"createGallery": "Galerie",
|
||||
"createInvoice": "Rechnung",
|
||||
"doc": {
|
||||
"quote": "Angebot",
|
||||
"contract": "Vertrag",
|
||||
"invoice": "Rechnung",
|
||||
"gallery": "Galerie"
|
||||
},
|
||||
"soon": "In einer späteren Phase verfügbar",
|
||||
"viewDocument": "Dokument ansehen",
|
||||
"openInAccounting": "Im Buchhaltungs-Posteingang öffnen",
|
||||
"noInboundBody": "Für diese E-Mail wurde kein Nachrichtentext erfasst.",
|
||||
"noBody": "Diese Nachricht wurde gesendet, bevor die Textspeicherung eingeführt wurde — keine Vorschau verfügbar.",
|
||||
"loadError": "Diese Nachricht konnte nicht geladen werden.",
|
||||
"attachments": "Anhang/Anhänge",
|
||||
"notArchived": "noch nicht archiviert",
|
||||
"sentAttachHint": "Gesendete Anhänge werden noch nicht archiviert — Phase 2.",
|
||||
"document": "Dokument",
|
||||
"previewUnavailable": "Vorschau nicht verfügbar",
|
||||
"rasterNote": "Serverseitig gerenderte Vorschau — die Originaldatei erreicht den Browser nie.",
|
||||
"close": "Schliessen",
|
||||
"compose": "Nachricht verfassen",
|
||||
"cancel": "Abbrechen",
|
||||
"send": "Senden",
|
||||
"subject": "Betreff",
|
||||
"optional": "optional",
|
||||
"bodyHint": "Bearbeite die Nachricht frei — füge vor dem Senden an beliebiger Stelle eine Notiz ein.",
|
||||
"sendsFromHint": "Wird von deiner konfigurierten Absenderadresse gesendet.",
|
||||
"sentToast": "Nachricht gesendet.",
|
||||
"sendFailed": "Nachricht konnte nicht gesendet werden.",
|
||||
"onWrote": "Am",
|
||||
"customer": "Kunde",
|
||||
"resolvingCustomer": "Absender wird einem Kunden zugeordnet…",
|
||||
"noCustomerMatch": "Kein Kunde zu diesem Absender gefunden — oben suchen oder neuen Kunden anlegen.",
|
||||
"createNewDoc": "Neues {{label}} erstellen",
|
||||
"existingDocs": "Oder ein bestehendes referenzieren",
|
||||
"noExistingDocs": "Für diesen Kunden gibt es noch keine Dokumente.",
|
||||
"galleryCreateOnly": "Galerien sind event-basiert — dies öffnet den Event-Editor, wo du den Kunden zuweisen kannst.",
|
||||
"syncOk": "Postfächer geprüft — {{count}} neu.",
|
||||
"syncDisabled": "Eingehende E-Mails sind deaktiviert — unter Einstellungen → Funktionen aktivieren.",
|
||||
"syncUnconfigured": "Zuerst ein Postfach unter Einstellungen → E-Mail konfigurieren.",
|
||||
"syncBusy": "Es läuft bereits eine Synchronisierung.",
|
||||
"syncFailed": "Synchronisierung fehlgeschlagen.",
|
||||
"actionFailed": "Aktion fehlgeschlagen."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +198,6 @@
|
||||
"dashboard": "Dashboard",
|
||||
"events": "Events",
|
||||
"archives": "Archives",
|
||||
"messages": "Messages",
|
||||
"settings": "Settings",
|
||||
"systemHealth": "System health",
|
||||
"eventTypes": "Event Types",
|
||||
@@ -2698,24 +2697,6 @@
|
||||
"backup": "Backup & restore",
|
||||
"system": "System updates",
|
||||
"other": "Other"
|
||||
},
|
||||
"customerMailbox": {
|
||||
"title": "Customer mailbox (hello@)",
|
||||
"subtitle": "A second inbound mailbox for customer conversations. Its mail appears under Messages → Customers; attachments are not routed to Accounting.",
|
||||
"enabled": "Poll this mailbox every minute",
|
||||
"outgoing": "Outgoing (SMTP)",
|
||||
"outgoingHint": "Replies from this mailbox send from here. Leave blank to fall back to the global outgoing address.",
|
||||
"fromEmail": "From address",
|
||||
"smtpHost": "SMTP Host",
|
||||
"smtpUser": "SMTP Username",
|
||||
"smtpPass": "SMTP Password",
|
||||
"smtpSsl": "SSL (465)",
|
||||
"smtpStarttls": "STARTTLS (587)",
|
||||
"save": "Save Customer Mailbox",
|
||||
"savedToast": "Customer mailbox saved.",
|
||||
"requiredFields": "Host, port and username are required.",
|
||||
"testOk": "Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.",
|
||||
"testFailed": "Connection failed."
|
||||
}
|
||||
},
|
||||
"cms": {
|
||||
@@ -5550,88 +5531,5 @@
|
||||
"titlePlaceholder": "e.g. Wedding contract Doe / Müller",
|
||||
"validUntil": "Sign by (optional)"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"title": "Messages",
|
||||
"subtitle": "Sent, automated and incoming mail — one place.",
|
||||
"sync": "Sync",
|
||||
"newMessage": "New message",
|
||||
"searchPlaceholder": "Search this folder…",
|
||||
"account": {
|
||||
"all": "All mail",
|
||||
"customers": "Customers",
|
||||
"accounting": "Accounting",
|
||||
"automated": "Automated"
|
||||
},
|
||||
"folder": {
|
||||
"inbox": "Inbox",
|
||||
"sent": "Sent",
|
||||
"archived": "Archived",
|
||||
"deleted": "Deleted"
|
||||
},
|
||||
"unified": "Unified across accounts",
|
||||
"systemGenerated": "System-generated",
|
||||
"acrossAccounts": "Across all accounts",
|
||||
"selectPrompt": "Select a message to read",
|
||||
"noMessages": "No messages",
|
||||
"noSearchResults": "No matches",
|
||||
"noSubject": "(no subject)",
|
||||
"from": "from",
|
||||
"to": "To",
|
||||
"reply": "Reply",
|
||||
"replyAll": "Reply all",
|
||||
"forward": "Forward",
|
||||
"archive": "Archive",
|
||||
"delete": "Delete",
|
||||
"deleteForever": "Delete permanently",
|
||||
"restore": "Restore",
|
||||
"bookExpense": "Book as expense",
|
||||
"rebill": "Re-bill to client",
|
||||
"createQuote": "Quote",
|
||||
"createContract": "Contract",
|
||||
"createGallery": "Gallery",
|
||||
"createInvoice": "Invoice",
|
||||
"doc": {
|
||||
"quote": "Quote",
|
||||
"contract": "Contract",
|
||||
"invoice": "Invoice",
|
||||
"gallery": "Gallery"
|
||||
},
|
||||
"soon": "Available in a later phase",
|
||||
"viewDocument": "View document",
|
||||
"openInAccounting": "Open in Accounting inbox",
|
||||
"noInboundBody": "No message body was captured for this email.",
|
||||
"noBody": "This message was sent before body capture was added, so no preview is available.",
|
||||
"loadError": "Could not load this message.",
|
||||
"attachments": "attachment(s)",
|
||||
"notArchived": "not archived yet",
|
||||
"sentAttachHint": "Sent attachments are not archived yet — Phase 2.",
|
||||
"document": "Document",
|
||||
"previewUnavailable": "Preview unavailable",
|
||||
"rasterNote": "Server-rendered preview — the raw file never reaches the browser.",
|
||||
"close": "Close",
|
||||
"compose": "Compose message",
|
||||
"cancel": "Cancel",
|
||||
"send": "Send",
|
||||
"subject": "Subject",
|
||||
"optional": "optional",
|
||||
"bodyHint": "Edit the message freely — add a note anywhere before sending.",
|
||||
"sendsFromHint": "Sends from your configured outgoing address.",
|
||||
"sentToast": "Message sent.",
|
||||
"sendFailed": "Failed to send message.",
|
||||
"onWrote": "On",
|
||||
"customer": "Customer",
|
||||
"resolvingCustomer": "Matching the sender to a customer…",
|
||||
"noCustomerMatch": "No customer matched this sender — search for one or create a new customer above.",
|
||||
"createNewDoc": "Create new {{label}}",
|
||||
"existingDocs": "Or reference an existing one",
|
||||
"noExistingDocs": "No existing documents for this customer yet.",
|
||||
"galleryCreateOnly": "Galleries are event-based — this opens the event editor, where you can assign the customer.",
|
||||
"syncOk": "Checked mailboxes — {{count}} new.",
|
||||
"syncDisabled": "Incoming mail is off — enable it under Settings → Features.",
|
||||
"syncUnconfigured": "Configure a mailbox under Settings → Email first.",
|
||||
"syncBusy": "A sync is already running.",
|
||||
"syncFailed": "Sync failed.",
|
||||
"actionFailed": "Action failed."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor'
|
||||
import { SentEmailsPanel } from '../../components/admin/SentEmailsPanel';
|
||||
import { ReceivedEmailsPanel } from '../../components/admin/ReceivedEmailsPanel';
|
||||
import { IncomingMailConfigCard } from '../../components/admin/IncomingMailConfigCard';
|
||||
import { CustomerMailboxCard } from '../../components/admin/CustomerMailboxCard';
|
||||
import { Palette, RefreshCw, Info } from 'lucide-react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useModal, useMutationWithToast } from '../../hooks';
|
||||
@@ -803,7 +802,6 @@ export const EmailConfigPage: React.FC = () => {
|
||||
{/* Email Templates Tab */}
|
||||
{/* Incoming mail (IMAP) — a second block under SMTP, flag-gated. */}
|
||||
{activeTab === 'smtp' && featureFlags.incomingMail && <IncomingMailConfigCard />}
|
||||
{activeTab === 'smtp' && featureFlags.messaging && <CustomerMailboxCard />}
|
||||
|
||||
{activeTab === 'templates' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams, useSearchParams, Link } from 'react-router-dom';
|
||||
import { useNavigate, useParams, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { ArrowLeft, Eye, Save } from 'lucide-react';
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
} from '../../../services/contracts.service';
|
||||
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
|
||||
import { ProjectSelect } from '../../../components/admin/ProjectSelect';
|
||||
import { customerAdminService } from '../../../services/customerAdmin.service';
|
||||
|
||||
interface BlockRow {
|
||||
blockId: number;
|
||||
@@ -40,7 +39,6 @@ interface BlockRow {
|
||||
export const ContractEditorPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const isEdit = Boolean(id);
|
||||
const numericId = id ? parseInt(id, 10) : null;
|
||||
@@ -69,28 +67,6 @@ export const ContractEditorPage: React.FC = () => {
|
||||
const [projectId, setProjectId] = useState<number | null>(null);
|
||||
const [blocks, setBlocks] = useState<BlockRow[]>([]);
|
||||
|
||||
// Prefill the customer when opened as "new contract for this customer"
|
||||
// (?customerAccountId=42), e.g. from the Messages view. New contracts only;
|
||||
// mirrors QuoteEditorPage / BillEditorPage.
|
||||
useEffect(() => {
|
||||
if (isEdit || customerAccountId) return;
|
||||
const raw = searchParams.get('customerAccountId');
|
||||
const cid = raw ? parseInt(raw, 10) : NaN;
|
||||
if (!Number.isFinite(cid) || cid <= 0) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const c = await customerAdminService.get(cid);
|
||||
if (cancelled) return;
|
||||
setCustomerAccountId(c.id);
|
||||
setCustomerLabel(c.companyName || c.displayName || [c.firstName, c.lastName].filter(Boolean).join(' ') || c.email);
|
||||
setCustomerIsPassive(Boolean(c.isPassive));
|
||||
if (c.preferredLanguage) setLanguage(c.preferredLanguage);
|
||||
} catch { /* ignore — admin can still pick manually */ }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [isEdit, searchParams, customerAccountId]);
|
||||
|
||||
// Load existing contract on edit.
|
||||
const { data: existing, isLoading: existingLoading } = useQuery({
|
||||
queryKey: ['contract', numericId],
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { X, Plus, FileText } from 'lucide-react';
|
||||
import { Button, Loading } from '../../../components/common';
|
||||
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
|
||||
import { customerAdminService } from '../../../services/customerAdmin.service';
|
||||
import { quotesService } from '../../../services/quotes.service';
|
||||
import { contractsService } from '../../../services/contracts.service';
|
||||
import { billsService } from '../../../services/bills.service';
|
||||
|
||||
/**
|
||||
* From a customer message: resolve (or pick/create) the customer, then either
|
||||
* create a NEW document of the given type (jumps to the real editor prefilled
|
||||
* with the customer) or SELECT an existing one to reference in a reply. Reuses
|
||||
* the CRM editors, list endpoints and CustomerPicker — no duplicated doc logic.
|
||||
*/
|
||||
export type DocType = 'quote' | 'contract' | 'invoice' | 'gallery';
|
||||
|
||||
const CONFIG: Record<DocType, { label: string; newRoute: string; hasExisting: boolean }> = {
|
||||
quote: { label: 'Quote', newRoute: '/admin/clients/quotes/new', hasExisting: true },
|
||||
contract: { label: 'Contract', newRoute: '/admin/clients/contracts/new', hasExisting: true },
|
||||
invoice: { label: 'Invoice', newRoute: '/admin/clients/bills/new', hasExisting: true },
|
||||
gallery: { label: 'Gallery', newRoute: '/admin/events/new', hasExisting: false },
|
||||
};
|
||||
|
||||
interface DocRow { id: number; number: string; status: string }
|
||||
|
||||
type SelCustomer = { id: number; email: string; label: string };
|
||||
|
||||
export const DocumentActionModal: React.FC<{
|
||||
docType: DocType;
|
||||
senderEmail: string;
|
||||
onCompose: (init: { to: string; subject: string; html: string }) => void;
|
||||
onClose: () => void;
|
||||
t: (k: string, d?: string) => string;
|
||||
}> = ({ docType, senderEmail, onCompose, onClose, t }) => {
|
||||
const navigate = useNavigate();
|
||||
const cfg = CONFIG[docType];
|
||||
const [customer, setCustomer] = useState<SelCustomer | null>(null);
|
||||
const [resolving, setResolving] = useState(true);
|
||||
|
||||
const pick = (c: { id: number; email: string; displayName?: string | null; companyName?: string | null }) =>
|
||||
setCustomer({ id: c.id, email: c.email, label: c.companyName || c.displayName || c.email });
|
||||
|
||||
// Resolve the customer from the message's sender address (first match).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setResolving(true);
|
||||
customerAdminService.search(senderEmail)
|
||||
.then((rows) => {
|
||||
if (cancelled) return;
|
||||
// search matches email/name/company PREFIXES — only auto-pick on an
|
||||
// EXACT email match so a spoofed/partial sender can't prefill the wrong
|
||||
// customer. Otherwise leave the picker for the admin to choose.
|
||||
const target = senderEmail.trim().toLowerCase();
|
||||
const exact = rows.find((r) => (r.email || '').toLowerCase() === target);
|
||||
if (exact) pick(exact);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { if (!cancelled) setResolving(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [senderEmail]);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', h);
|
||||
return () => window.removeEventListener('keydown', h);
|
||||
}, [onClose]);
|
||||
|
||||
const existing = useQuery({
|
||||
queryKey: ['messages', 'docs', docType, customer?.id],
|
||||
enabled: !!customer && cfg.hasExisting,
|
||||
queryFn: async (): Promise<DocRow[]> => {
|
||||
const customerAccountId = customer!.id;
|
||||
if (docType === 'quote') {
|
||||
const r = await quotesService.list({ customerAccountId, page: 1, pageSize: 20 });
|
||||
return r.quotes.map((q) => ({ id: q.id, number: q.quoteNumber, status: q.status }));
|
||||
}
|
||||
if (docType === 'contract') {
|
||||
const r = await contractsService.list({ customerAccountId, page: 1, pageSize: 20 });
|
||||
return r.contracts.map((c) => ({ id: c.id, number: c.contractNumber, status: c.status }));
|
||||
}
|
||||
const r = await billsService.list({ customerAccountId, page: 1, pageSize: 20 });
|
||||
return r.invoices.map((i) => ({ id: i.id, number: i.invoiceNumber, status: i.status }));
|
||||
},
|
||||
});
|
||||
|
||||
const createNew = () => {
|
||||
if (!customer && docType !== 'gallery') return;
|
||||
navigate(docType === 'gallery' || !customer ? cfg.newRoute : `${cfg.newRoute}?customerAccountId=${customer.id}`);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const pickExisting = (d: DocRow) => {
|
||||
const html = `<p><br></p><p>${cfg.label} <strong>${d.number}</strong></p><p><br></p>`;
|
||||
onCompose({ to: customer?.email || senderEmail, subject: `${cfg.label} ${d.number}`, html });
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center bg-black/55 p-4" onClick={onClose}>
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-xl w-[min(560px,96vw)] max-h-[88vh] flex flex-col overflow-hidden shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-neutral-200 dark:border-neutral-800">
|
||||
<span className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
{t(`messages.doc.${docType}`, cfg.label)}
|
||||
</span>
|
||||
<button onClick={onClose} className="ml-auto w-8 h-8 grid place-items-center rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800" aria-label={t('messages.close', 'Close')}>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex flex-col gap-4 overflow-y-auto">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('messages.customer', 'Customer')}</div>
|
||||
<CustomerPicker
|
||||
value={customer?.id ?? null}
|
||||
label={customer?.label || ''}
|
||||
onSelect={pick}
|
||||
onCreate={pick}
|
||||
onClear={() => setCustomer(null)}
|
||||
/>
|
||||
{resolving && <p className="mt-1 text-xs text-neutral-400">{t('messages.resolvingCustomer', 'Matching the sender to a customer…')}</p>}
|
||||
{!resolving && !customer && (
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('messages.noCustomerMatch', 'No customer matched this sender — search for one or create a new customer above.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{customer && (
|
||||
<>
|
||||
<Button variant="primary" onClick={createNew} leftIcon={<Plus className="w-4 h-4" />} className="w-full justify-center">
|
||||
{t('messages.createNewDoc', 'Create new {{label}}', { label: t(`messages.doc.${docType}`, cfg.label) } as any)}
|
||||
</Button>
|
||||
|
||||
{cfg.hasExisting && (
|
||||
<div>
|
||||
<div className="text-[11px] font-bold uppercase tracking-wide text-neutral-400 mb-2">
|
||||
{t('messages.existingDocs', 'Or reference an existing one')}
|
||||
</div>
|
||||
{existing.isLoading ? (
|
||||
<Loading />
|
||||
) : (existing.data && existing.data.length > 0) ? (
|
||||
<div className="flex flex-col gap-1.5 max-h-[38vh] overflow-y-auto">
|
||||
{existing.data.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
onClick={() => pickExisting(d)}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-neutral-200 dark:border-neutral-800 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 text-left"
|
||||
>
|
||||
<FileText className="w-4 h-4 text-neutral-400 flex-none" />
|
||||
<span className="font-mono text-[13px] text-neutral-800 dark:text-neutral-100">{d.number}</span>
|
||||
<span className="ml-auto text-[11px] text-neutral-400">{d.status}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">{t('messages.noExistingDocs', 'No existing documents for this customer yet.')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!cfg.hasExisting && (
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('messages.galleryCreateOnly', 'Galleries are event-based — this opens the event editor, where you can assign the customer.')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentActionModal;
|
||||
@@ -1,119 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { X, Send as SendIcon } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { emailService } from '../../../services/email.service';
|
||||
import { Button } from '../../../components/common';
|
||||
|
||||
/**
|
||||
* Compose / reply modal. The body is pre-loaded with the rendered template (or a
|
||||
* reply stub) and is FULLY EDITABLE — the admin can rewrite it or drop a note
|
||||
* anywhere before sending. On send it goes out as-is (server-sanitized), no
|
||||
* template re-render, and is recorded as a manual send (Customers ▸ Sent).
|
||||
*/
|
||||
export interface ComposerInit {
|
||||
to: string;
|
||||
cc?: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
replyToReceivedId?: number;
|
||||
}
|
||||
|
||||
const inputCls = 'flex-1 px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-950 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-accent';
|
||||
|
||||
export const MessageComposer: React.FC<{
|
||||
init: ComposerInit;
|
||||
title?: string;
|
||||
accountKey?: string;
|
||||
onClose: () => void;
|
||||
onSent: () => void;
|
||||
t: (k: string, d?: string) => string;
|
||||
}> = ({ init, title, accountKey, onClose, onSent, t }) => {
|
||||
const [to, setTo] = useState(init.to);
|
||||
const [cc, setCc] = useState(init.cc || '');
|
||||
const [subject, setSubject] = useState(init.subject);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Sanitize before it hits the contentEditable innerHTML — the initial body
|
||||
// can include untrusted text (e.g. an inbound sender name in a reply stub).
|
||||
if (bodyRef.current) bodyRef.current.innerHTML = DOMPurify.sanitize(init.html || '');
|
||||
// Load initial body exactly once; further edits are the admin's.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', h);
|
||||
return () => window.removeEventListener('keydown', h);
|
||||
}, [onClose]);
|
||||
|
||||
const send = useMutation({
|
||||
mutationFn: () => emailService.sendMessage({
|
||||
to: to.trim(),
|
||||
cc: cc.trim() || undefined,
|
||||
subject: subject.trim(),
|
||||
html: bodyRef.current?.innerHTML || '',
|
||||
replyToReceivedId: init.replyToReceivedId,
|
||||
accountKey,
|
||||
}),
|
||||
onSuccess: () => { toast.success(t('messages.sentToast', 'Message sent.')); onSent(); onClose(); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('messages.sendFailed', 'Failed to send message.')),
|
||||
});
|
||||
|
||||
const canSend = !!to.trim() && !!subject.trim() && !send.isPending;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center bg-black/55 p-4" onClick={onClose}>
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-xl w-[min(920px,97vw)] h-[min(780px,92vh)] flex flex-col overflow-hidden shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-neutral-200 dark:border-neutral-800">
|
||||
<span className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">{title || t('messages.compose', 'Compose message')}</span>
|
||||
<button onClick={onClose} className="ml-auto w-8 h-8 grid place-items-center rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800" aria-label={t('messages.close', 'Close')}>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex flex-col gap-3 overflow-y-auto flex-1 min-h-0">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span className="w-16 text-neutral-500 dark:text-neutral-400">{t('messages.to', 'To')}</span>
|
||||
<input className={inputCls} value={to} onChange={(e) => setTo(e.target.value)} placeholder="name@example.com" />
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span className="w-16 text-neutral-500 dark:text-neutral-400">Cc</span>
|
||||
<input className={inputCls} value={cc} onChange={(e) => setCc(e.target.value)} placeholder={t('messages.optional', 'optional')} />
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span className="w-16 text-neutral-500 dark:text-neutral-400">{t('messages.subject', 'Subject')}</span>
|
||||
<input className={inputCls} value={subject} onChange={(e) => setSubject(e.target.value)} />
|
||||
</label>
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
{t('messages.bodyHint', 'Edit the message freely — add a note anywhere before sending.')}
|
||||
</div>
|
||||
<div
|
||||
ref={bodyRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
className="min-h-[240px] flex-1 overflow-y-auto rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-950 p-3 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-t border-neutral-200 dark:border-neutral-800">
|
||||
<span className="text-xs text-neutral-400">{t('messages.sendsFromHint', 'Sends from your configured outgoing address.')}</span>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button variant="outline" onClick={onClose}>{t('messages.cancel', 'Cancel')}</Button>
|
||||
<Button variant="primary" onClick={() => send.mutate()} isLoading={send.isPending} disabled={!canSend} leftIcon={<SendIcon className="w-4 h-4" />}>
|
||||
{t('messages.send', 'Send')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageComposer;
|
||||
@@ -1,822 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
Inbox, Send, Reply, ReplyAll, Forward, Archive, Trash2, Paperclip,
|
||||
FileText, Quote, FileSignature, Image as ImageIcon, ReceiptText,
|
||||
Link2, X, ChevronLeft, ChevronRight, Mail, RefreshCw, PenSquare, Search, RotateCcw, type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { emailService, type ReceivedEmail, type MailIdentities } from '../../../services/email.service';
|
||||
import { accountingService } from '../../../services/accounting.service';
|
||||
import { Loading } from '../../../components/common';
|
||||
import { MessageComposer, type ComposerInit } from './MessageComposer';
|
||||
import { DocumentActionModal, type DocType } from './DocumentActionModal';
|
||||
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
|
||||
|
||||
/**
|
||||
* Admin "Messages" — read-only viewer over the mail picpeak already
|
||||
* has: the Automated stream (email_queue, incl. rendered bodies from migration
|
||||
* 119) and the Accounting inbox (received_emails / supplier invoices). The
|
||||
* Customers (hello@) mailbox and reply/compose land in later phases; those
|
||||
* folders render an explanatory empty state so the full IA is visible now.
|
||||
*/
|
||||
|
||||
type FolderSrc = 'queue' | 'received' | 'empty' | 'state';
|
||||
interface Folder { id: string; name: string; icon: LucideIcon; src: FolderSrc; account?: string; origin?: 'system' | 'manual'; state?: 'archived' | 'deleted'; note?: string; }
|
||||
interface Account { id: string; name: string; addr?: string; color: string; folders: Folder[]; }
|
||||
|
||||
type Selection =
|
||||
| { kind: 'queue'; id: number }
|
||||
| { kind: 'received'; item: ReceivedEmail }
|
||||
| null;
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
invoice_sent: 'Invoice sent',
|
||||
invoice_reminder_first: 'Payment reminder',
|
||||
invoice_reminder_second: 'Payment reminder',
|
||||
invoice_reminder_final: 'Final reminder',
|
||||
invoice_payment_check: 'Payment check',
|
||||
invoice_collections_handoff: 'Collections handoff',
|
||||
invoice_paid_admin_notification: 'Payment received',
|
||||
expiration_warning: 'Gallery expiring',
|
||||
gallery_expired: 'Gallery expired',
|
||||
quote_sent: 'Quote sent',
|
||||
contract_sent: 'Contract sent',
|
||||
};
|
||||
const friendlyType = (t: string) =>
|
||||
TYPE_LABELS[t] || t.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
const fmt = (s?: string | null) =>
|
||||
s ? new Date(s).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : '';
|
||||
|
||||
// Compact mailbox label — just the local part + '@' (the domain clutters the
|
||||
// narrow sidebar); full address stays in the hover title.
|
||||
const localPart = (addr?: string | null) => (addr ? `${addr.split('@')[0]}@` : '');
|
||||
|
||||
// Escape untrusted text before it goes into an HTML string. The inbound From
|
||||
// header carries an attacker-controlled display name; the reply stub builds raw
|
||||
// HTML for the (contentEditable) composer, so this MUST be escaped there.
|
||||
const escapeHtml = (s: string) =>
|
||||
s.replace(/[&<>"']/g, (c) => (({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } as Record<string, string>)[c]));
|
||||
|
||||
// A From/To header can be "Display Name <addr@x>" — pull the bare address for
|
||||
// use as a recipient / customer-lookup key.
|
||||
const extractEmail = (addr?: string | null) => {
|
||||
if (!addr) return '';
|
||||
const m = addr.match(/<([^>]+)>/);
|
||||
return (m ? m[1] : addr).trim();
|
||||
};
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
sent: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||
ingested: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||
received: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
pending: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||
error: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||
};
|
||||
|
||||
export const MessagesPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [activeFolder, setActiveFolder] = useState('auto-sent');
|
||||
const [selection, setSelection] = useState<Selection>(null);
|
||||
const [pdfDocId, setPdfDocId] = useState<number | null>(null);
|
||||
const [composer, setComposer] = useState<{ init: ComposerInit; title?: string; accountKey?: string } | null>(null);
|
||||
const [docAction, setDocAction] = useState<{ docType: DocType; senderEmail: string } | null>(null);
|
||||
const { flags } = useFeatureFlags();
|
||||
const [search, setSearch] = useState('');
|
||||
// Debounced copy drives the server-side search (so results aren't truncated to
|
||||
// the first page); the raw `search` still filters the loaded rows instantly.
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebouncedSearch(search.trim()), 250);
|
||||
return () => clearTimeout(id);
|
||||
}, [search]);
|
||||
const sq = debouncedSearch || undefined;
|
||||
|
||||
// "Sync" = poll the inbound mailboxes now instead of waiting for the 60s loop.
|
||||
const sync = useMutation({
|
||||
mutationFn: () => emailService.pollIncoming(),
|
||||
onSuccess: (r) => {
|
||||
if (r.skipped === 'disabled') toast.info(t('messages.syncDisabled', 'Incoming mail is off — enable it under Settings → Features.'));
|
||||
else if (r.skipped === 'unconfigured') toast.info(t('messages.syncUnconfigured', 'Configure a mailbox under Settings → Email first.'));
|
||||
else if (r.skipped === 'busy') toast.info(t('messages.syncBusy', 'A sync is already running.'));
|
||||
else toast.success(t('messages.syncOk', 'Checked mailboxes — {{count}} new.', { count: r.processed || 0 }));
|
||||
acctQuery.refetch(); custQuery.refetch(); queueQuery.refetch();
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('messages.syncFailed', 'Sync failed.')),
|
||||
});
|
||||
|
||||
const openNewMessage = () => setComposer({
|
||||
init: { to: '', subject: '', html: '' },
|
||||
title: t('messages.newMessage', 'New message'),
|
||||
accountKey: 'customers',
|
||||
});
|
||||
|
||||
const queueQuery = useQuery({
|
||||
queryKey: ['messages', 'queue', sq],
|
||||
queryFn: () => emailService.listQueue({ pageSize: 100, q: sq }),
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
const acctQuery = useQuery({
|
||||
queryKey: ['messages', 'received', 'accounting', sq],
|
||||
queryFn: () => emailService.listReceived({ account: 'accounting', pageSize: 100, q: sq }),
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
const custQuery = useQuery({
|
||||
queryKey: ['messages', 'received', 'customers', sq],
|
||||
queryFn: () => emailService.listReceived({ account: 'customers', pageSize: 100, q: sq }),
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
||||
const identitiesQuery = useQuery({
|
||||
queryKey: ['messages', 'identities'],
|
||||
queryFn: () => emailService.getIdentities(),
|
||||
});
|
||||
const identities = identitiesQuery.data;
|
||||
|
||||
// Archived / Deleted system folders — fetch queue + received for that state,
|
||||
// on demand (only when the folder is open).
|
||||
const folderState: 'archived' | 'deleted' | undefined =
|
||||
activeFolder === 'archived' ? 'archived' : activeFolder === 'deleted' ? 'deleted' : undefined;
|
||||
const stateQueueQuery = useQuery({
|
||||
queryKey: ['messages', 'state-queue', folderState, sq],
|
||||
enabled: !!folderState,
|
||||
queryFn: () => emailService.listQueue({ state: folderState as 'archived' | 'deleted', pageSize: 100, q: sq }),
|
||||
});
|
||||
const stateRecvQuery = useQuery({
|
||||
queryKey: ['messages', 'state-received', folderState, sq],
|
||||
enabled: !!folderState,
|
||||
queryFn: () => emailService.listReceived({ state: folderState as 'archived' | 'deleted', pageSize: 100, q: sq }),
|
||||
});
|
||||
|
||||
const refetchAll = () => {
|
||||
queueQuery.refetch(); acctQuery.refetch(); custQuery.refetch();
|
||||
stateQueueQuery.refetch(); stateRecvQuery.refetch();
|
||||
};
|
||||
const stateMut = useMutation({
|
||||
mutationFn: (v: { kind: 'queue' | 'received'; id: number; state: 'active' | 'archived' | 'deleted' }) =>
|
||||
emailService.setItemState(v.kind, v.id, v.state),
|
||||
onSuccess: () => { setSelection(null); refetchAll(); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('messages.actionFailed', 'Action failed.')),
|
||||
});
|
||||
const purgeMut = useMutation({
|
||||
mutationFn: (v: { kind: 'queue' | 'received'; id: number }) => emailService.deleteItem(v.kind, v.id),
|
||||
onSuccess: () => { setSelection(null); refetchAll(); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('messages.actionFailed', 'Action failed.')),
|
||||
});
|
||||
// Archive / Delete (soft) / Restore, acting on the current selection. Delete
|
||||
// from the Deleted folder is permanent.
|
||||
const doItemAction = (action: 'archive' | 'delete' | 'restore') => {
|
||||
if (!selection) return;
|
||||
const kind = selection.kind;
|
||||
const id = selection.kind === 'queue' ? selection.id : selection.item.id;
|
||||
if (action === 'restore') stateMut.mutate({ kind, id, state: 'active' });
|
||||
else if (action === 'archive') stateMut.mutate({ kind, id, state: 'archived' });
|
||||
else if (folderState === 'deleted') purgeMut.mutate({ kind, id });
|
||||
else stateMut.mutate({ kind, id, state: 'deleted' });
|
||||
};
|
||||
|
||||
const queueTotal = queueQuery.data?.pagination.total;
|
||||
const acctTotal = acctQuery.data?.pagination.total;
|
||||
const custTotal = custQuery.data?.pagination.total;
|
||||
|
||||
const accounts: Account[] = useMemo(() => [
|
||||
{ id: 'all', name: t('messages.account.all', 'All mail'), color: '#64748b', folders: [
|
||||
{ id: 'all-in', name: t('messages.folder.inbox', 'Inbox'), icon: Inbox, src: 'received' },
|
||||
{ id: 'all-sent', name: t('messages.folder.sent', 'Sent'), icon: Send, src: 'queue' },
|
||||
] },
|
||||
{ id: 'cust', name: t('messages.account.customers', 'Customers'), addr: identities?.customers || undefined, color: '#2563c9', folders: [
|
||||
{ id: 'cust-in', name: t('messages.folder.inbox', 'Inbox'), icon: Inbox, src: 'received', account: 'customers' },
|
||||
{ id: 'cust-sent', name: t('messages.folder.sent', 'Sent'), icon: Send, src: 'queue', origin: 'manual' },
|
||||
] },
|
||||
{ id: 'acct', name: t('messages.account.accounting', 'Accounting'), addr: identities?.accounting || undefined, color: '#12876a', folders: [
|
||||
{ id: 'acct-in', name: t('messages.folder.inbox', 'Inbox'), icon: Inbox, src: 'received', account: 'accounting' },
|
||||
] },
|
||||
{ id: 'auto', name: t('messages.account.automated', 'Automated'), addr: identities?.automated || undefined, color: '#7a52d6', folders: [
|
||||
{ id: 'auto-sent', name: t('messages.folder.sent', 'Sent'), icon: Send, src: 'queue', origin: 'system' },
|
||||
] },
|
||||
], [t, identities]);
|
||||
|
||||
// Cross-account system folders — Archived + Deleted (trash).
|
||||
const systemFolders: Folder[] = useMemo(() => [
|
||||
{ id: 'archived', name: t('messages.folder.archived', 'Archived'), icon: Archive, src: 'state', state: 'archived' },
|
||||
{ id: 'deleted', name: t('messages.folder.deleted', 'Deleted'), icon: Trash2, src: 'state', state: 'deleted' },
|
||||
], [t]);
|
||||
|
||||
// Sent stream is split client-side by origin: system (Automated) vs manual
|
||||
// (human composed → Customers ▸ Sent). Legacy rows (origin undefined) = system.
|
||||
const queueItemsAll = queueQuery.data?.items || [];
|
||||
const queueFor = (origin?: 'system' | 'manual') =>
|
||||
origin === 'manual' ? queueItemsAll.filter((i) => i.origin === 'manual')
|
||||
: origin === 'system' ? queueItemsAll.filter((i) => i.origin !== 'manual')
|
||||
: queueItemsAll;
|
||||
|
||||
const folder = useMemo(() => {
|
||||
for (const a of accounts) for (const f of a.folders) if (f.id === activeFolder) return { a, f };
|
||||
const sf = systemFolders.find((f) => f.id === activeFolder);
|
||||
if (sf) return { a: { id: 'system', name: sf.name, color: '#94a3b8', folders: [] } as Account, f: sf };
|
||||
return { a: accounts[0], f: accounts[0].folders[0] };
|
||||
}, [accounts, systemFolders, activeFolder]);
|
||||
|
||||
const countFor = (f: Folder): number | undefined => {
|
||||
if (f.src === 'queue') return f.origin ? queueFor(f.origin).length : queueTotal;
|
||||
if (f.src === 'received') {
|
||||
if (f.account === 'customers') return custTotal;
|
||||
if (f.account === 'accounting') return acctTotal;
|
||||
return (acctTotal || 0) + (custTotal || 0);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Which received rows feed the active folder (customer / accounting / union).
|
||||
const receivedItems = useMemo(() => {
|
||||
if (folder.f.src !== 'received') return undefined;
|
||||
const a = acctQuery.data?.items || [];
|
||||
const c = custQuery.data?.items || [];
|
||||
if (folder.f.account === 'customers') return c;
|
||||
if (folder.f.account === 'accounting') return a;
|
||||
return [...a, ...c].sort((x, y) => (y.received_at || '').localeCompare(x.received_at || ''));
|
||||
}, [folder, acctQuery.data, custQuery.data]);
|
||||
|
||||
const receivedLoading = folder.f.account === 'customers'
|
||||
? custQuery.isLoading
|
||||
: folder.f.account === 'accounting'
|
||||
? acctQuery.isLoading
|
||||
: acctQuery.isLoading || custQuery.isLoading;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-8.5rem)] min-h-[540px]">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="flex-none">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||
<Mail className="w-6 h-6 text-neutral-500 dark:text-neutral-400" />
|
||||
{t('messages.title', 'Messages')}
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-0.5">
|
||||
{t('messages.subtitle', 'Sent, automated and incoming mail — one place.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative flex-1 max-w-md ml-auto">
|
||||
<Search className="w-4 h-4 text-neutral-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('messages.searchPlaceholder', 'Search this folder…')}
|
||||
className="w-full h-9 pl-9 pr-3 rounded-lg border border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-none">
|
||||
<button
|
||||
onClick={() => sync.mutate()}
|
||||
disabled={sync.isPending}
|
||||
className="inline-flex items-center gap-2 h-9 px-3 rounded-lg border border-neutral-300 dark:border-neutral-700 text-sm font-medium text-neutral-700 dark:text-neutral-200 hover:bg-neutral-50 dark:hover:bg-neutral-800 disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${sync.isPending ? 'animate-spin' : ''}`} />
|
||||
{t('messages.sync', 'Sync')}
|
||||
</button>
|
||||
<button
|
||||
onClick={openNewMessage}
|
||||
className="inline-flex items-center gap-2 h-9 px-3.5 rounded-lg bg-accent-dark text-white text-sm font-medium hover:opacity-90"
|
||||
>
|
||||
<PenSquare className="w-4 h-4" />
|
||||
{t('messages.newMessage', 'New message')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 min-h-0 rounded-xl border border-neutral-200 dark:border-neutral-800 overflow-hidden bg-white dark:bg-neutral-900">
|
||||
{/* ── account tree ── */}
|
||||
<nav className="w-56 flex-none border-r border-neutral-200 dark:border-neutral-800 overflow-y-auto p-2 bg-neutral-50 dark:bg-neutral-950/40">
|
||||
{accounts.map((a) => (
|
||||
<div key={a.id} className="mb-1.5">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-sm font-semibold text-neutral-800 dark:text-neutral-200">
|
||||
<span className="w-2 h-2 rounded-full flex-none" style={{ background: a.color }} />
|
||||
<span>{a.name}</span>
|
||||
{a.addr && <span title={a.addr} className="ml-auto text-[11px] font-medium font-mono text-neutral-400 dark:text-neutral-500 truncate max-w-[7rem]">{localPart(a.addr)}</span>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{a.folders.map((f) => {
|
||||
const c = countFor(f);
|
||||
const active = f.id === activeFolder;
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => { setActiveFolder(f.id); setSelection(null); }}
|
||||
className={`flex items-center gap-2 pl-7 pr-2 py-1.5 rounded-lg text-[13.5px] text-left transition-colors ${
|
||||
active
|
||||
? 'bg-accent-soft text-on-accent-soft font-semibold'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800/60'
|
||||
}`}
|
||||
>
|
||||
<f.icon className="w-4 h-4 opacity-80" />
|
||||
<span>{f.name}</span>
|
||||
{typeof c === 'number' && c > 0 && (
|
||||
<span className={`ml-auto tabular-nums text-xs ${active ? 'text-on-accent-soft' : 'text-neutral-400'}`}>{c}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* System folders — Archived + Deleted, across all accounts. */}
|
||||
<div className="mt-2 pt-2 border-t border-neutral-200 dark:border-neutral-800 flex flex-col gap-0.5">
|
||||
{systemFolders.map((f) => {
|
||||
const active = f.id === activeFolder;
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => { setActiveFolder(f.id); setSelection(null); }}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-[13.5px] text-left transition-colors ${
|
||||
active
|
||||
? 'bg-accent-soft text-on-accent-soft font-semibold'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800/60'
|
||||
}`}
|
||||
>
|
||||
<f.icon className="w-4 h-4 opacity-80" />
|
||||
<span>{f.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ── message list ── */}
|
||||
<section className="w-[22rem] flex-none flex flex-col min-h-0 border-r border-neutral-200 dark:border-neutral-800">
|
||||
<div className="px-4 py-3 border-b border-neutral-200 dark:border-neutral-800 flex-none">
|
||||
<div className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{folder.f.name}</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||
{folder.f.src === 'state'
|
||||
? t('messages.acrossAccounts', 'Across all accounts')
|
||||
: folder.a.addr || (folder.a.id === 'all' ? t('messages.unified', 'Unified across accounts') : t('messages.systemGenerated', 'System-generated'))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<MessageList
|
||||
folder={folder.f}
|
||||
queue={folder.f.src === 'state' ? stateQueueQuery.data?.items : queueFor(folder.f.origin)}
|
||||
received={folder.f.src === 'state' ? stateRecvQuery.data?.items : receivedItems}
|
||||
loading={folder.f.src === 'state'
|
||||
? (stateQueueQuery.isLoading || stateRecvQuery.isLoading)
|
||||
: folder.f.src === 'queue' ? queueQuery.isLoading : folder.f.src === 'received' ? receivedLoading : false}
|
||||
search={search}
|
||||
selection={selection}
|
||||
onSelect={setSelection}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── reading pane ── */}
|
||||
<section className="flex-1 min-w-0 flex flex-col min-h-0">
|
||||
<ReadingPane
|
||||
selection={selection}
|
||||
account={folder.a}
|
||||
identities={identities}
|
||||
flags={flags}
|
||||
folderState={folderState}
|
||||
onViewDoc={setPdfDocId}
|
||||
onOpenAccounting={() => navigate('/admin/accounting/inbox')}
|
||||
onCompose={(init, title) => setComposer({ init, title, accountKey: 'customers' })}
|
||||
onOpenDoc={(docType, senderEmail) => setDocAction({ docType, senderEmail })}
|
||||
onItemAction={doItemAction}
|
||||
t={t}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{pdfDocId != null && <PdfModal docId={pdfDocId} onClose={() => setPdfDocId(null)} t={t} />}
|
||||
{composer && (
|
||||
<MessageComposer
|
||||
init={composer.init}
|
||||
title={composer.title}
|
||||
accountKey={composer.accountKey}
|
||||
onClose={() => setComposer(null)}
|
||||
onSent={() => { queueQuery.refetch(); setActiveFolder('cust-sent'); }}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{docAction && (
|
||||
<DocumentActionModal
|
||||
docType={docAction.docType}
|
||||
senderEmail={docAction.senderEmail}
|
||||
onCompose={(init) => { setDocAction(null); setComposer({ init: { to: init.to, subject: init.subject, html: init.html }, title: init.subject, accountKey: 'customers' }); }}
|
||||
onClose={() => setDocAction(null)}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────── message list ──
|
||||
const MessageList: React.FC<{
|
||||
folder: Folder;
|
||||
queue?: import('../../../services/email.service').EmailQueueItem[];
|
||||
received?: ReceivedEmail[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
selection: Selection;
|
||||
onSelect: (s: Selection) => void;
|
||||
t: (k: string, d?: string) => string;
|
||||
}> = ({ folder, queue, received, loading, search, selection, onSelect, t }) => {
|
||||
if (folder.src === 'empty') {
|
||||
return (
|
||||
<div className="p-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<Inbox className="w-8 h-8 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" />
|
||||
{folder.note}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loading) return <div className="p-6"><Loading /></div>;
|
||||
|
||||
const qRows = (queue || []).map((m) => ({
|
||||
key: `q${m.id}`,
|
||||
sortKey: m.sentAt || m.createdAt || '',
|
||||
onClick: () => onSelect({ kind: 'queue', id: m.id }),
|
||||
active: selection?.kind === 'queue' && selection.id === m.id,
|
||||
who: m.recipientEmail,
|
||||
subject: friendlyType(m.emailType),
|
||||
when: fmt(m.sentAt || m.createdAt),
|
||||
status: m.status,
|
||||
attach: 0,
|
||||
}));
|
||||
const rRows = (received || []).map((m) => ({
|
||||
key: `r${m.id}`,
|
||||
sortKey: m.received_at || '',
|
||||
onClick: () => onSelect({ kind: 'received', item: m }),
|
||||
active: selection?.kind === 'received' && selection.item.id === m.id,
|
||||
who: m.from_address || '—',
|
||||
subject: m.subject || t('messages.noSubject', '(no subject)'),
|
||||
when: fmt(m.received_at),
|
||||
status: m.status,
|
||||
attach: m.attachment_count,
|
||||
}));
|
||||
|
||||
// Archived/Deleted folders (src 'state') merge both streams by date.
|
||||
let rows = folder.src === 'queue' ? qRows
|
||||
: folder.src === 'received' ? rRows
|
||||
: [...qRows, ...rRows].sort((a, b) => (b.sortKey || '').localeCompare(a.sortKey || ''));
|
||||
|
||||
const q = search.trim().toLowerCase();
|
||||
if (q) rows = rows.filter((r) => r.who.toLowerCase().includes(q) || r.subject.toLowerCase().includes(q));
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <div className="p-8 text-center text-sm text-neutral-500 dark:text-neutral-400">{q ? t('messages.noSearchResults', 'No matches') : t('messages.noMessages', 'No messages')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{rows.map((r) => (
|
||||
<li key={r.key}>
|
||||
<button
|
||||
onClick={r.onClick}
|
||||
className={`w-full text-left px-4 py-3 border-b border-neutral-100 dark:border-neutral-800/70 border-l-[3px] transition-colors ${
|
||||
r.active
|
||||
? 'border-l-accent-dark bg-accent-soft'
|
||||
: 'border-l-transparent hover:bg-neutral-50 dark:hover:bg-neutral-800/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-[13.5px] text-neutral-800 dark:text-neutral-100 truncate">{r.who}</span>
|
||||
<span className="ml-auto text-[11px] text-neutral-400 tabular-nums whitespace-nowrap">{r.when}</span>
|
||||
</div>
|
||||
<div className="text-[13px] text-neutral-600 dark:text-neutral-300 truncate mt-0.5">{r.subject}</div>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className={`text-[10.5px] font-semibold px-1.5 py-0.5 rounded-full ${STATUS_STYLES[r.status] || 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300'}`}>
|
||||
{r.status}
|
||||
</span>
|
||||
{r.attach > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-neutral-400">
|
||||
<Paperclip className="w-3 h-3" />{r.attach}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────── reading pane ──
|
||||
const ReadingPane: React.FC<{
|
||||
selection: Selection;
|
||||
account: Account;
|
||||
identities?: MailIdentities | null;
|
||||
flags: Record<string, boolean>;
|
||||
folderState?: 'archived' | 'deleted';
|
||||
onViewDoc: (id: number) => void;
|
||||
onOpenAccounting: () => void;
|
||||
onCompose: (init: ComposerInit, title?: string) => void;
|
||||
onOpenDoc: (docType: DocType, senderEmail: string) => void;
|
||||
onItemAction: (action: 'archive' | 'delete' | 'restore') => void;
|
||||
t: (k: string, d?: string) => string;
|
||||
}> = ({ selection, account, identities, flags, folderState, onViewDoc, onOpenAccounting, onCompose, onOpenDoc, onItemAction, t }) => {
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ['messages', 'queue', selection?.kind === 'queue' ? selection.id : null],
|
||||
queryFn: () => emailService.getQueueItem((selection as { kind: 'queue'; id: number }).id),
|
||||
enabled: selection?.kind === 'queue',
|
||||
});
|
||||
|
||||
if (!selection) {
|
||||
return (
|
||||
<div className="flex-1 grid place-items-center text-center text-neutral-400 dark:text-neutral-500 p-10">
|
||||
<div>
|
||||
<Mail className="w-9 h-9 mx-auto mb-3 text-neutral-300 dark:text-neutral-700" />
|
||||
<div className="text-sm">{t('messages.selectPrompt', 'Select a message to read')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Accounting toolbar only for the rechnungen@ stream; customer mail (inbound
|
||||
// or the automated/sent streams) gets the CRM action set.
|
||||
const isAcct = selection.kind === 'received'
|
||||
? selection.item.account_key !== 'customers'
|
||||
: account.id === 'acct';
|
||||
|
||||
const recipient = extractEmail(selection.kind === 'received'
|
||||
? selection.item.from_address
|
||||
: detailQuery.data?.recipientEmail);
|
||||
|
||||
// Reply only makes sense for an inbound message with a sender.
|
||||
const onReply = selection.kind === 'received' && selection.item.from_address
|
||||
? () => {
|
||||
const it = selection.item;
|
||||
const subj = /^re:/i.test(it.subject || '') ? (it.subject || '') : `Re: ${it.subject || ''}`;
|
||||
const quoted = `<p><br></p><p style="color:#888;font-size:12px">${t('messages.onWrote', 'On')} ${fmt(it.received_at)}, ${escapeHtml(it.from_address || '')}:</p>`;
|
||||
onCompose({ to: extractEmail(it.from_address), subject: subj, html: quoted, replyToReceivedId: it.id }, t('messages.reply', 'Reply'));
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Quote/Contract/Invoice/Gallery open the document-action flow (resolve the
|
||||
// customer, then create-new or select-existing). Customer-facing streams only.
|
||||
const onDoc = !isAcct && recipient
|
||||
? (docType: DocType) => onOpenDoc(docType, recipient)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-0 flex-1">
|
||||
<Toolbar isAcct={isAcct} flags={flags} folderState={folderState} onReply={onReply} onDoc={onDoc} onItemAction={onItemAction} t={t} />
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{selection.kind === 'queue' ? (
|
||||
detailQuery.isLoading ? <Loading /> : detailQuery.data ? (
|
||||
<QueueDetail d={detailQuery.data} fromAddr={identities?.automated} t={t} />
|
||||
) : (
|
||||
<div className="text-sm text-neutral-500">{t('messages.loadError', 'Could not load this message.')}</div>
|
||||
)
|
||||
) : (
|
||||
<ReceivedDetail
|
||||
item={selection.item}
|
||||
mailboxAddr={selection.item.account_key === 'customers' ? identities?.customers : identities?.accounting}
|
||||
onViewDoc={onViewDoc}
|
||||
onOpenAccounting={onOpenAccounting}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const QueueDetail: React.FC<{ d: import('../../../services/email.service').EmailQueueDetail; fromAddr?: string | null; t: (k: string, d?: string) => string }> = ({ d, fromAddr, t }) => (
|
||||
<>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100" style={{ textWrap: 'balance' } as React.CSSProperties}>
|
||||
{friendlyType(d.emailType)}
|
||||
</h2>
|
||||
<div className="mt-3 pb-4 border-b border-neutral-200 dark:border-neutral-800 text-sm">
|
||||
<div className="text-neutral-600 dark:text-neutral-300">
|
||||
{t('messages.from', 'from')} <span className="font-mono text-xs">{fromAddr || '—'}</span> · {t('messages.to', 'to')}{' '}
|
||||
<span className="font-semibold text-neutral-800 dark:text-neutral-100">{d.recipientEmail}</span>
|
||||
</div>
|
||||
{d.cc && <div className="text-neutral-500 dark:text-neutral-400 text-xs mt-0.5">cc {d.cc}</div>}
|
||||
<div className="text-neutral-400 dark:text-neutral-500 text-xs mt-0.5 tabular-nums">{fmt(d.sentAt || d.createdAt)}</div>
|
||||
</div>
|
||||
|
||||
{d.renderedHtml ? (
|
||||
<div className="mt-4 rounded-lg border border-neutral-200 dark:border-neutral-800 overflow-hidden bg-white" style={{ height: '52vh' }}>
|
||||
{/* Our own template output, but rendered with a strict script-less,
|
||||
no-same-origin sandbox anyway — matches the inbound-mail pane. */}
|
||||
<iframe title="Email body" sandbox="" srcDoc={d.renderedHtml} className="w-full h-full border-0" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 text-sm text-neutral-500 dark:text-neutral-400 italic">
|
||||
{t('messages.noBody', 'This message was sent before body capture was added, so no preview is available.')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{d.attachments.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<div className="text-[11px] font-bold uppercase tracking-wide text-neutral-400 mb-2">
|
||||
{d.attachments.length} {t('messages.attachments', 'attachment(s)')}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 max-w-md">
|
||||
{d.attachments.map((a, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/40">
|
||||
<FileText className="w-5 h-5 text-red-500 flex-none" />
|
||||
<span className="text-[13.5px] font-medium text-neutral-800 dark:text-neutral-100 truncate">{a.filename}</span>
|
||||
<span className="ml-auto text-[11px] text-neutral-400" title={t('messages.sentAttachHint', 'Sent attachments are not archived yet — Phase 2.')}>
|
||||
{t('messages.notArchived', 'not archived yet')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const ReceivedDetail: React.FC<{
|
||||
item: ReceivedEmail;
|
||||
mailboxAddr?: string | null;
|
||||
onViewDoc: (id: number) => void;
|
||||
onOpenAccounting: () => void;
|
||||
t: (k: string, d?: string) => string;
|
||||
}> = ({ item, mailboxAddr, onViewDoc, onOpenAccounting, t }) => {
|
||||
const detail = useQuery({
|
||||
queryKey: ['messages', 'received', 'item', item.id],
|
||||
queryFn: () => emailService.getReceivedItem(item.id),
|
||||
});
|
||||
const toAddr = detail.data?.to_address || item.to_address || mailboxAddr || '—';
|
||||
return (
|
||||
<>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100" style={{ textWrap: 'balance' } as React.CSSProperties}>
|
||||
{item.subject || t('messages.noSubject', '(no subject)')}
|
||||
</h2>
|
||||
<div className="mt-3 pb-4 border-b border-neutral-200 dark:border-neutral-800 text-sm">
|
||||
<div className="text-neutral-600 dark:text-neutral-300">
|
||||
{t('messages.from', 'from')} <span className="font-semibold text-neutral-800 dark:text-neutral-100">{item.from_address || '—'}</span>
|
||||
{' · '}{t('messages.to', 'to')} <span className="font-mono text-xs">{toAddr}</span>
|
||||
</div>
|
||||
<div className="text-neutral-400 dark:text-neutral-500 text-xs mt-0.5 tabular-nums">{fmt(item.received_at)}</div>
|
||||
</div>
|
||||
|
||||
{detail.isLoading ? (
|
||||
<div className="mt-4"><Loading /></div>
|
||||
) : detail.data?.body_html ? (
|
||||
<div className="mt-4 rounded-lg border border-neutral-200 dark:border-neutral-800 overflow-hidden bg-white" style={{ height: '48vh' }}>
|
||||
{/* Sanitized server-side; rendered with a strict (script-less, no
|
||||
same-origin) sandbox as a second layer against untrusted mail. */}
|
||||
<iframe title="Email body" sandbox="" srcDoc={detail.data.body_html} className="w-full h-full border-0" />
|
||||
</div>
|
||||
) : detail.data?.body_text ? (
|
||||
<pre className="mt-4 whitespace-pre-wrap text-sm text-neutral-700 dark:text-neutral-300 font-sans">{detail.data.body_text}</pre>
|
||||
) : (
|
||||
<div className="mt-4 text-sm text-neutral-500 dark:text-neutral-400 italic">
|
||||
{t('messages.noInboundBody', 'No message body was captured for this email.')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.inbound_document_id != null && (
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => onViewDoc(item.inbound_document_id as number)}
|
||||
className="inline-flex items-center gap-2 px-3.5 py-2 rounded-lg bg-accent-dark hover:opacity-90 text-white text-sm font-medium"
|
||||
>
|
||||
<FileText className="w-4 h-4" />{t('messages.viewDocument', 'View document')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenAccounting}
|
||||
className="inline-flex items-center gap-2 px-3.5 py-2 rounded-lg border border-neutral-300 dark:border-neutral-700 text-neutral-700 dark:text-neutral-200 text-sm font-medium hover:bg-neutral-50 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<Link2 className="w-4 h-4" />{t('messages.openInAccounting', 'Open in Accounting inbox')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{item.error && (
|
||||
<div className="mt-4 text-sm text-red-600 dark:text-red-400">{item.error}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────── toolbar ──
|
||||
const Toolbar: React.FC<{
|
||||
isAcct: boolean;
|
||||
flags: Record<string, boolean>;
|
||||
folderState?: 'archived' | 'deleted';
|
||||
onReply?: () => void;
|
||||
onDoc?: (docType: DocType) => void;
|
||||
onItemAction: (action: 'archive' | 'delete' | 'restore') => void;
|
||||
t: (k: string, d?: string) => string;
|
||||
}> = ({ isAcct, flags, folderState, onReply, onDoc, onItemAction, t }) => {
|
||||
const Tb: React.FC<{ icon: LucideIcon; label: string; accent?: boolean; onClick?: () => void }> = ({ icon: Icon, label, accent, onClick }) => {
|
||||
const enabled = !!onClick;
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={!enabled}
|
||||
title={enabled ? undefined : t('messages.soon', 'Available in a later phase')}
|
||||
className={`inline-flex items-center gap-1.5 h-8 px-2.5 rounded-lg text-[13px] font-medium ${
|
||||
enabled ? 'hover:bg-neutral-100 dark:hover:bg-neutral-800 ' : 'cursor-not-allowed opacity-50 '
|
||||
}${accent ? 'text-accent-dark font-semibold' : 'text-neutral-600 dark:text-neutral-300'}`}
|
||||
>
|
||||
<Icon className="w-[15px] h-[15px]" />{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
const doc = (docType: DocType) => (onDoc ? () => onDoc(docType) : undefined);
|
||||
return (
|
||||
<div className="flex items-center gap-1 flex-wrap px-3 py-2 border-b border-neutral-200 dark:border-neutral-800 flex-none">
|
||||
<Tb icon={Reply} label={t('messages.reply', 'Reply')} onClick={onReply} />
|
||||
<Tb icon={ReplyAll} label={t('messages.replyAll', 'Reply all')} />
|
||||
<Tb icon={Forward} label={t('messages.forward', 'Forward')} />
|
||||
<span className="w-px h-5 bg-neutral-200 dark:bg-neutral-700 mx-1" />
|
||||
{isAcct ? (
|
||||
<>
|
||||
<Tb icon={ReceiptText} label={t('messages.bookExpense', 'Book as expense')} accent />
|
||||
<Tb icon={Forward} label={t('messages.rebill', 'Re-bill to client')} accent />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{flags.quotes && <Tb icon={Quote} label={t('messages.createQuote', 'Quote')} accent onClick={doc('quote')} />}
|
||||
{flags.contracts && <Tb icon={FileSignature} label={t('messages.createContract', 'Contract')} accent onClick={doc('contract')} />}
|
||||
<Tb icon={ImageIcon} label={t('messages.createGallery', 'Gallery')} accent onClick={doc('gallery')} />
|
||||
{flags.bills && <Tb icon={FileText} label={t('messages.createInvoice', 'Invoice')} accent onClick={doc('invoice')} />}
|
||||
</>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{folderState && <Tb icon={RotateCcw} label={t('messages.restore', 'Restore')} onClick={() => onItemAction('restore')} />}
|
||||
{folderState !== 'archived' && <Tb icon={Archive} label={t('messages.archive', 'Archive')} onClick={() => onItemAction('archive')} />}
|
||||
<Tb
|
||||
icon={Trash2}
|
||||
label={folderState === 'deleted' ? t('messages.deleteForever', 'Delete permanently') : t('messages.delete', 'Delete')}
|
||||
onClick={() => onItemAction('delete')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────── pdf modal ──
|
||||
const PdfModal: React.FC<{ docId: number; onClose: () => void; t: (k: string, d?: string) => string }> = ({ docId, onClose, t }) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [err, setErr] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let revoked: string | null = null;
|
||||
let cancelled = false;
|
||||
setErr(false);
|
||||
setUrl(null);
|
||||
accountingService.getInboundPageBlob(docId, page)
|
||||
.then((blob) => {
|
||||
if (cancelled) return;
|
||||
const u = URL.createObjectURL(blob);
|
||||
revoked = u;
|
||||
setUrl(u);
|
||||
})
|
||||
.catch(() => { if (!cancelled) setErr(true); });
|
||||
return () => { cancelled = true; if (revoked) URL.revokeObjectURL(revoked); };
|
||||
}, [docId, page]);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', h);
|
||||
return () => window.removeEventListener('keydown', h);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center bg-black/55 p-6" onClick={onClose}>
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-xl w-[min(620px,94vw)] max-h-[90vh] flex flex-col overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-neutral-200 dark:border-neutral-800">
|
||||
<FileText className="w-4 h-4 text-red-500" />
|
||||
<span className="text-sm font-medium text-neutral-800 dark:text-neutral-100">{t('messages.document', 'Document')}</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}
|
||||
className="w-8 h-8 grid place-items-center rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-40">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-xs tabular-nums text-neutral-500 w-6 text-center">{page}</span>
|
||||
<button onClick={() => setPage((p) => p + 1)}
|
||||
className="w-8 h-8 grid place-items-center rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={onClose} aria-label={t('messages.close', 'Close')}
|
||||
className="w-8 h-8 grid place-items-center rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800 ml-1">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-auto p-5 bg-neutral-100 dark:bg-neutral-800 grid place-items-center min-h-[240px]">
|
||||
{err ? (
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400">{t('messages.previewUnavailable', 'Preview unavailable')}</div>
|
||||
) : url ? (
|
||||
<img src={url} alt="" className="max-w-full shadow-lg rounded" />
|
||||
) : (
|
||||
<Loading />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center text-[11px] text-neutral-400 py-2 border-t border-neutral-200 dark:border-neutral-800">
|
||||
{t('messages.rasterNote', 'Server-rendered preview — the raw file never reaches the browser.')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessagesPage;
|
||||
@@ -15,8 +15,6 @@ export interface EmailQueueItem {
|
||||
eventId: number | null;
|
||||
eventName: string | null;
|
||||
eventSlug: string | null;
|
||||
/** 'system' = app-generated (Automated), 'manual' = admin-composed (Customers Sent). */
|
||||
origin?: 'system' | 'manual';
|
||||
}
|
||||
|
||||
export interface EmailQueueListResponse {
|
||||
@@ -24,15 +22,6 @@ export interface EmailQueueListResponse {
|
||||
pagination: { total: number; page: number; pageSize: number; totalPages: number };
|
||||
}
|
||||
|
||||
/** Single sent/queued email including its rendered body — Messages reading pane. */
|
||||
export interface EmailQueueDetail extends EmailQueueItem {
|
||||
/** Exact HTML that was sent (migration 119); null for pre-migration rows. */
|
||||
renderedHtml: string | null;
|
||||
cc: string | null;
|
||||
/** Attachment filenames only — disk paths are never exposed. */
|
||||
attachments: { filename: string; contentType: string | null }[];
|
||||
}
|
||||
|
||||
export interface EmailConfig {
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
@@ -127,9 +116,7 @@ export interface ImapPollResult {
|
||||
export interface ReceivedEmail {
|
||||
id: number;
|
||||
message_id: string | null;
|
||||
account_key?: string | null;
|
||||
from_address: string | null;
|
||||
to_address?: string | null;
|
||||
subject: string | null;
|
||||
received_at: string | null;
|
||||
attachment_count: number;
|
||||
@@ -138,46 +125,11 @@ export interface ReceivedEmail {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** Single received email including its captured, server-sanitized body. */
|
||||
export interface ReceivedEmailDetail extends ReceivedEmail {
|
||||
body_html: string | null;
|
||||
body_text: string | null;
|
||||
}
|
||||
|
||||
export interface ReceivedEmailsResponse {
|
||||
items: ReceivedEmail[];
|
||||
pagination: { page: number; pageSize: number; total: number; totalPages: number };
|
||||
}
|
||||
|
||||
/** An additional inbound mailbox beyond the primary accounting IMAP. */
|
||||
export interface MailAccount {
|
||||
id?: number;
|
||||
account_key: string;
|
||||
label?: string | null;
|
||||
imap_host?: string | null;
|
||||
imap_port?: number;
|
||||
imap_secure?: boolean;
|
||||
imap_user?: string | null;
|
||||
imap_pass?: string;
|
||||
imap_folder?: string;
|
||||
// Outgoing (SMTP) identity — replies from this mailbox send from here.
|
||||
smtp_host?: string | null;
|
||||
smtp_port?: number;
|
||||
smtp_secure?: boolean;
|
||||
smtp_user?: string | null;
|
||||
smtp_pass?: string;
|
||||
from_email?: string | null;
|
||||
from_name?: string | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/** Resolved sender/mailbox addresses for the Messages sidebar. */
|
||||
export interface MailIdentities {
|
||||
automated: string | null;
|
||||
accounting: string | null;
|
||||
customers: string | null;
|
||||
}
|
||||
|
||||
export const emailService = {
|
||||
// Get email configuration
|
||||
async getConfig(): Promise<EmailConfig> {
|
||||
@@ -220,34 +172,10 @@ export const emailService = {
|
||||
const response = await api.post<ImapPollResult>('/admin/email/incoming-config/poll', {});
|
||||
return response.data;
|
||||
},
|
||||
async listReceived(params: { page?: number; pageSize?: number; account?: string; state?: 'active' | 'archived' | 'deleted'; q?: string } = {}): Promise<ReceivedEmailsResponse> {
|
||||
async listReceived(params: { page?: number; pageSize?: number } = {}): Promise<ReceivedEmailsResponse> {
|
||||
const response = await api.get<ReceivedEmailsResponse>('/admin/email/received', { params });
|
||||
return response.data;
|
||||
},
|
||||
/** Archive / Delete (soft) / Restore an email. kind = 'queue' | 'received'. */
|
||||
async setItemState(kind: 'queue' | 'received', id: number, state: 'active' | 'archived' | 'deleted'): Promise<void> {
|
||||
await api.post(`/admin/email/item/${kind}/${id}/state`, { state });
|
||||
},
|
||||
/** Permanently delete an email (only from the Deleted folder). */
|
||||
async deleteItem(kind: 'queue' | 'received', id: number): Promise<void> {
|
||||
await api.delete(`/admin/email/item/${kind}/${id}`);
|
||||
},
|
||||
async getReceivedItem(id: number): Promise<ReceivedEmailDetail> {
|
||||
const response = await api.get<ReceivedEmailDetail>(`/admin/email/received/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
// Additional inbound mailboxes (e.g. the customer hello@ box).
|
||||
async listMailAccounts(): Promise<MailAccount[]> {
|
||||
const response = await api.get<{ items: MailAccount[] }>('/admin/email/accounts');
|
||||
return response.data.items;
|
||||
},
|
||||
async saveMailAccount(account: MailAccount): Promise<void> {
|
||||
await api.post('/admin/email/accounts', account);
|
||||
},
|
||||
async testMailAccount(account: Partial<MailAccount>): Promise<ImapTestResult> {
|
||||
const response = await api.post<ImapTestResult>('/admin/email/accounts/test', account);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Test email configuration
|
||||
async testEmail(testEmail: string): Promise<void> {
|
||||
@@ -269,8 +197,6 @@ export const emailService = {
|
||||
async listQueue(params: {
|
||||
status?: EmailQueueStatus;
|
||||
emailType?: string;
|
||||
origin?: 'system' | 'manual';
|
||||
state?: 'active' | 'archived' | 'deleted';
|
||||
q?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
@@ -281,23 +207,6 @@ export const emailService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** Single sent email with its rendered body + attachment filenames. */
|
||||
async getQueueItem(id: number): Promise<EmailQueueDetail> {
|
||||
const response = await api.get<EmailQueueDetail>(`/admin/email/queue/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** Send a human-composed (edited) email — reply or document message. */
|
||||
async sendMessage(payload: { to: string; cc?: string; subject: string; html: string; replyToReceivedId?: number; accountKey?: string }): Promise<void> {
|
||||
await api.post('/admin/email/send', payload);
|
||||
},
|
||||
|
||||
/** Resolved sender/mailbox addresses for the Messages sidebar. */
|
||||
async getIdentities(): Promise<MailIdentities> {
|
||||
const response = await api.get<MailIdentities>('/admin/email/identities');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get all email templates
|
||||
async getTemplates(): Promise<EmailTemplate[]> {
|
||||
const response = await api.get<EmailTemplate[]>('/admin/email/templates');
|
||||
|
||||
@@ -201,10 +201,9 @@ export const eventsService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Extend event expiration (admin). Uses the canonical, ownership-guarded
|
||||
// route; the old /events/:id/extend legacy endpoint was removed (GHSA-4j34).
|
||||
// Extend event expiration (admin)
|
||||
async extendExpiration(id: number, days: number): Promise<Event> {
|
||||
const response = await api.post<Event>(`/admin/events/${id}/extend`, {
|
||||
const response = await api.post<Event>(`/events/${id}/extend`, {
|
||||
days,
|
||||
});
|
||||
return response.data;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
Reference in New Issue
Block a user