Compare commits

..
16 Commits
Author SHA1 Message Date
Bernd Rodler 2116798281 feat(jitsi): video meetings — /api/jitsi/token route + jitsi-meet plugin + feature gate
Publish Docker Image / prepare (push) Successful in 2s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 8s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-25 23:17:29 +02:00
Andrea a457c1770e fix(auth): guard OAuth callback against double token exchange
Publish Docker Image / prepare (push) Successful in 3s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 9s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-24 17:59:42 +02:00
Andrea 88bca86c1e fix(calendar): send invitation emails client-side via iMIP
Publish Docker Image / prepare (push) Successful in 7s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 7s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-24 17:23:47 +02:00
Andrea 60fde141b5 fix(calendar): wrap event-modal footer buttons so Save stays visible
Publish Docker Image / prepare (push) Successful in 3s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 20s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-24 14:16:17 +02:00
Andrea e693072862 fix(calendar): open event modal directly in edit mode
Publish Docker Image / prepare (push) Successful in 1s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 8s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-24 13:57:02 +02:00
Andrea 7c127d8183 fix(calendar): treat empty-string recurrenceId as non-recurring when editing
Publish Docker Image / prepare (push) Successful in 11s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 51s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-24 13:34:00 +02:00
Andrea cf93b24abc fix(calendar): allow editing own events when Stalwart omits isOrigin
Publish Docker Image / prepare (push) Successful in 3s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 10s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-24 13:09:51 +02:00
Andrea d103c47cda fix(signatures): place caret above the signature in compose and above-quote replies
Publish Docker Image / prepare (push) Successful in 4s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 10s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-24 10:36:24 +02:00
Andrea 20dadea2dd fix(signatures): place reply/forward signature above the quote by default
Publish Docker Image / prepare (push) Successful in 1s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 18s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-23 19:21:55 +02:00
Andrea e696c65f75 fix(signatures): apply reply signature to replies and forwards
Publish Docker Image / prepare (push) Successful in 3s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 19s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-23 19:04:49 +02:00
Andrea 33ca4bae37 fix(email): sender popover Email action opens in-app composer instead of mailto:
Publish Docker Image / prepare (push) Successful in 1s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 1m7s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-23 17:24:29 +02:00
Andrea 424dba39f7 fix(theme): SRC theme fonts — Inter + Spectral per styleguide
Publish Docker Image / prepare (push) Successful in 0s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 8s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-23 17:05:57 +02:00
Andrea 16853a364c fix(theme): restore compose FAB icon + circular shape on SRC theme
Publish Docker Image / prepare (push) Successful in 1s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 13s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-23 10:56:53 +02:00
Bernd Rodler 8f2c89f9a8 feat(plugin): register manage-users in FIRST_PARTY_PLUGINS (manageUsersEnabled gate)
Publish Docker Image / prepare (push) Successful in 3s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 16s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-20 19:30:37 +02:00
Bernd Rodler 54c508e8df fix(build): add @types/ws for Electron WebSocket import
Publish Docker Image / prepare (push) Successful in 4s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 25s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-20 19:04:13 +02:00
Bernd Rodler d021a0a87c feat(plugin): Manage users — VNCdirectory deep-link settings-section 2026-08-20 18:40:10 +02:00
34 changed files with 1450 additions and 56 deletions
@@ -9,6 +9,11 @@ import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
// Module-level guard so a Suspense/search-params remount of this client
// component can't exchange the same OAuth code twice — Keycloak rejects a
// reused code with `invalid_grant` ("Code not valid") and the login fails.
const processedAuthCodes = new Set<string>();
function OAuthCallbackInner() { function OAuthCallbackInner() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
@@ -32,6 +37,11 @@ function OAuthCallbackInner() {
return; return;
} }
// Prevent a second token exchange for the same code (remount / double
// effect). Without this, the second exchange fails with "Code not valid".
if (processedAuthCodes.has(code)) return;
processedAuthCodes.add(code);
// Step-up re-auth for device pairing: the QR generator sent the user here // Step-up re-auth for device pairing: the QR generator sent the user here
// via prompt=login. Don't create a login session — just confirm the fresh // via prompt=login. Don't create a login session — just confirm the fresh
// auth (sets the short-lived pairing proof cookie) and bounce back to the // auth (sets the short-lived pairing proof cookie) and bounce back to the
+4 -1
View File
@@ -71,7 +71,10 @@ type PendingScopeAction =
| { type: "delete"; event: CalendarEvent; sendScheduling?: boolean }; | { type: "delete"; event: CalendarEvent; sendScheduling?: boolean };
function isRecurringEvent(event: CalendarEvent): boolean { function isRecurringEvent(event: CalendarEvent): boolean {
return (event.recurrenceRules?.length ?? 0) > 0 || event.recurrenceId != null; // Stalwart may return an empty-string `recurrenceId` for non-recurring events
// rather than null; treat that as non-recurring so editing doesn't route
// through the recurrence-scope flow for a plain single event.
return (event.recurrenceRules?.length ?? 0) > 0 || Boolean(event.recurrenceId);
} }
export default function CalendarPage() { export default function CalendarPage() {
+12 -3
View File
@@ -61,6 +61,7 @@ import { Input } from "@/components/ui/input";
import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { isFilePreviewable } from "@/lib/file-preview"; import { isFilePreviewable } from "@/lib/file-preview";
import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils"; import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils";
import { useSignatureStore } from "@/stores/signature-store";
import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { findDraftIdentityId, resolveReplyFrom, type ReplyFromResolution } from "@/lib/reply-identity"; import { findDraftIdentityId, resolveReplyFrom, type ReplyFromResolution } from "@/lib/reply-identity";
@@ -2615,8 +2616,16 @@ export default function Home() {
// Append signature from the sending identity (fall back to primary // Append signature from the sending identity (fall back to primary
// when the reply-from lives on the same identity but a different alias). // when the reply-from lives on the same identity but a different alias).
// The signature store's reply signature takes precedence over the legacy
// identity signature, matching the composer's send path.
const signatureStore = useSignatureStore.getState();
const replySigId = signatureStore.getIdentityReplySignatureId(sendingIdentity?.id ?? '');
const replySig = replySigId ? signatureStore.getSignatureById(replySigId) : undefined;
const signatureSource = replySig
? { htmlSignature: replySig.body, textSignature: replySig.plainText }
: sendingIdentity;
const separator = useSettingsStore.getState().signatureSeparatorEnabled; const separator = useSettingsStore.getState().signatureSeparatorEnabled;
const finalBody = appendPlainTextSignature(body, sendingIdentity, { separator }); const finalBody = appendPlainTextSignature(body, signatureSource, { separator });
// When the identity has an HTML signature, send a matching HTML body so the // When the identity has an HTML signature, send a matching HTML body so the
// signature keeps its formatting; appendPlainTextSignature would otherwise // signature keeps its formatting; appendPlainTextSignature would otherwise
@@ -2627,8 +2636,8 @@ export default function Home() {
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
.replace(/\n/g, '<br>'); .replace(/\n/g, '<br>');
const finalHtmlBody = sendingIdentity?.htmlSignature?.trim() const finalHtmlBody = signatureSource?.htmlSignature?.trim()
? appendHtmlSignature(`<div>${escapedBody}</div>`, sendingIdentity, { separator }) ? appendHtmlSignature(`<div>${escapedBody}</div>`, signatureSource, { separator })
: undefined; : undefined;
const originalEmailId = selectedEmail.id; const originalEmailId = selectedEmail.id;
+77
View File
@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createHmac } from 'node:crypto';
import { decryptSession } from '@/lib/auth/crypto';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { logger } from '@/lib/logger';
const JITSI_URL = (process.env.JITSI_URL || 'https://meet.src-advisory.com').replace(/\/+$/, '');
function base64url(input: Buffer): string {
return input.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function b64u(input: string): string {
return Buffer.from(input).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export async function POST(request: NextRequest) {
try {
const appId = process.env.JITSI_APP_ID;
const appSecret = process.env.JITSI_APP_SECRET;
if (!appId || !appSecret) {
return NextResponse.json({ error: 'Jitsi is not configured' }, { status: 503 });
}
const cookieStore = await cookies();
const sessionToken = cookieStore.get(sessionCookieName(0))?.value;
if (!sessionToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const session = decryptSession(sessionToken);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const email = session.username;
const body = await request.json().catch(() => ({}));
const room = typeof body.room === 'string' ? body.room.trim() : '';
if (!room || !/^[a-z0-9-]{1,100}$/i.test(room)) {
return NextResponse.json({ error: 'Invalid room name' }, { status: 400 });
}
const domain = new URL(JITSI_URL).hostname;
const now = Math.floor(Date.now() / 1000);
const header = { alg: 'HS256', typ: 'JWT' };
const payload = {
iss: 'bulwark-webmail',
sub: domain,
aud: appId,
room,
iat: now,
exp: now + 86400,
context: {
user: {
email,
name: email.split('@')[0],
},
},
};
const signingInput = `${b64u(JSON.stringify(header))}.${b64u(JSON.stringify(payload))}`;
const signature = createHmac('sha256', appSecret).update(signingInput).digest();
const token = `${signingInput}.${base64url(signature)}`;
logger.info('Jitsi token issued', { room, email });
return NextResponse.json({
token,
room,
url: `${JITSI_URL}/${encodeURIComponent(room)}`,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Jitsi token issuance failed', { error: message });
return NextResponse.json({ error: message }, { status: 500 });
}
}
+2 -1
View File
@@ -182,7 +182,8 @@ export function EventDetailPopover({
const isAttendeeMode = useMemo(() => { const isAttendeeMode = useMemo(() => {
if (!event.participants) return false; if (!event.participants) return false;
return !event.isOrigin && !userIsOrganizer; if (userIsOrganizer) return false;
return event.isOrigin === false;
}, [event, userIsOrganizer]); }, [event, userIsOrganizer]);
const userParticipantId = useMemo( const userParticipantId = useMemo(
+15 -8
View File
@@ -217,7 +217,9 @@ export function EventModal({
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
const isEdit = !!event; const isEdit = !!event;
const formatEventDate = useFormatEventDate(); const formatEventDate = useFormatEventDate();
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit"); // Open directly in edit mode so the fields are immediately editable. The
// read-only summary (view mode) is still reachable via the Cancel button.
const [mode, setMode] = useState<"view" | "edit">("edit");
const userIsOrganizer = useMemo(() => { const userIsOrganizer = useMemo(() => {
if (!event) return true; if (!event) return true;
@@ -227,7 +229,12 @@ export function EventModal({
const isAttendeeMode = useMemo(() => { const isAttendeeMode = useMemo(() => {
if (!event || !event.participants) return false; if (!event || !event.participants) return false;
return !event.isOrigin && !userIsOrganizer; // Only enter attendee (read-only + RSVP) mode when we are definitively NOT
// the organizer AND the event explicitly did not originate from this
// account. Stalwart may omit `isOrigin`, so treat a missing value as "ours"
// (editable) rather than locking the user out of their own events.
if (userIsOrganizer) return false;
return event.isOrigin === false;
}, [event, userIsOrganizer]); }, [event, userIsOrganizer]);
const userParticipantId = useMemo(() => { const userParticipantId = useMemo(() => {
@@ -1147,8 +1154,8 @@ export function EventModal({
</div> </div>
{/* Action Bar */} {/* Action Bar */}
<div className="px-6 py-3 border-t border-border flex-shrink-0 flex items-center justify-between"> <div className="px-6 py-3 border-t border-border flex-shrink-0 flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1"> <div className="flex flex-wrap items-center gap-1 min-w-0">
{onDelete && ( {onDelete && (
showDeleteConfirm ? ( showDeleteConfirm ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -1187,7 +1194,7 @@ export function EventModal({
)} )}
</div> </div>
{!showDeleteConfirm && ( {!showDeleteConfirm && (
<Button onClick={() => setMode("edit")}> <Button onClick={() => setMode("edit")} className="ml-auto shrink-0">
<Pencil className="w-4 h-4 me-1" /> <Pencil className="w-4 h-4 me-1" />
{t("events.edit")} {t("events.edit")}
</Button> </Button>
@@ -1618,8 +1625,8 @@ export function EventModal({
</div> </div>
</div> </div>
<div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0"> <div className="flex flex-wrap items-center gap-2 px-6 py-4 border-t border-border flex-shrink-0">
<div className="flex items-center gap-1"> <div className="flex flex-wrap items-center gap-1 min-w-0">
{isEdit && onDelete && ( {isEdit && onDelete && (
showDeleteConfirm ? ( showDeleteConfirm ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -1670,7 +1677,7 @@ export function EventModal({
)} )}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2 ml-auto shrink-0">
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}> <Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
{t("form.cancel")} {t("form.cancel")}
</Button> </Button>
+21 -7
View File
@@ -653,6 +653,15 @@ export function EmailComposer({
? currentIdentity ? currentIdentity
: primaryIdentity; : primaryIdentity;
// The signature store (default/reply/per-identity) takes precedence over the
// legacy per-identity html/text signature. `selectedSignature` is resolved in
// resolveStoreSignatureId for the current mode (compose → default; reply/
// forward → reply), so replies and forwards pick up the reply signature.
// Falls back to the legacy identity signature when no store signature is set.
const effectiveSignature = selectedSignature
? { htmlSignature: selectedSignature.body, textSignature: selectedSignature.plainText }
: signatureIdentity;
// Hold the TipTap editor instance so we can swap the embedded signature // Hold the TipTap editor instance so we can swap the embedded signature
// when the user switches identity in "above quote" mode without rebuilding // when the user switches identity in "above quote" mode without rebuilding
// the whole body (which would lose user edits to the surrounding draft). // the whole body (which would lose user edits to the surrounding draft).
@@ -751,8 +760,12 @@ export function EmailComposer({
sigInsertedRef.current = true; sigInsertedRef.current = true;
if (mode === 'compose') { if (mode === 'compose') {
editor.chain().focus('end').insertContent(`<p></p>${sig.body}`).run(); editor.chain().focus('end').insertContent(`<p></p>${sig.body}`).run();
// Place the caret in the empty paragraph above the signature so the user
// starts typing at the top of the new email.
editor.chain().focus('start').run();
} else if ((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote') { } else if ((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote') {
editor.chain().focus('start').insertContent(sig.body).run(); editor.chain().focus('start').insertContent(`<p></p>${sig.body}`).run();
editor.chain().focus('start').run();
} }
}, [selectedSignatureId, plainTextMode, mode, signaturePosition, getSignatureById, editorReady]); }, [selectedSignatureId, plainTextMode, mode, signaturePosition, getSignatureById, editorReady]);
@@ -1883,6 +1896,7 @@ export function EmailComposer({
// duplicate it. // duplicate it.
const signatureAlreadyInBody = const signatureAlreadyInBody =
shouldEmbedSignatureInNewMail || shouldEmbedSignatureInNewMail ||
(!plainTextMode && !!selectedSignature && mode === 'compose') ||
((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && ((mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
signaturePosition === 'above_quote'); signaturePosition === 'above_quote');
@@ -1890,11 +1904,11 @@ export function EmailComposer({
const buildSignatureHtml = (): string => { const buildSignatureHtml = (): string => {
if (signatureAlreadyInBody) return ''; if (signatureAlreadyInBody) return '';
const sep = signatureSeparatorEnabled ? `<br><br>-- <br>` : `<br><br>`; const sep = signatureSeparatorEnabled ? `<br><br>-- <br>` : `<br><br>`;
if (signatureIdentity?.htmlSignature) { if (effectiveSignature?.htmlSignature) {
return `${sep}${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}`; return `${sep}${sanitizeSignatureHtml(effectiveSignature.htmlSignature)}`;
} }
if (signatureIdentity?.textSignature) { if (effectiveSignature?.textSignature) {
return `${sep}${signatureIdentity.textSignature.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}`; return `${sep}${effectiveSignature.textSignature.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}`;
} }
return ''; return '';
}; };
@@ -1907,8 +1921,8 @@ export function EmailComposer({
// In plain text mode, send text/plain only (no HTML body) // In plain text mode, send text/plain only (no HTML body)
const signatureOpts = { separator: signatureSeparatorEnabled }; const signatureOpts = { separator: signatureSeparatorEnabled };
const finalBody = plainTextMode const finalBody = plainTextMode
? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts)) ? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, effectiveSignature, signatureOpts))
: (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity, signatureOpts)); : (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), effectiveSignature, signatureOpts));
const rewritten = plainTextMode ? null : rewriteInlineImages(body); const rewritten = plainTextMode ? null : rewriteInlineImages(body);
const finalHtmlBody = plainTextMode const finalHtmlBody = plainTextMode
+21 -3
View File
@@ -2,11 +2,14 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { Mail, Phone, Building, ExternalLink, Copy, Send, UserPlus } from "lucide-react"; import { Mail, Phone, Building, ExternalLink, Copy, Send, UserPlus } from "lucide-react";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useContactStore, getContactDisplayName } from "@/stores/contact-store"; import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { savePendingMailto, notifyPendingMailto } from "@/lib/protocol-handlers/session";
import { formatRecipient } from "@/lib/email-composer-utils";
import type { ContactCard } from "@/lib/jmap/types"; import type { ContactCard } from "@/lib/jmap/types";
interface RecipientPopoverProps { interface RecipientPopoverProps {
@@ -125,6 +128,21 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
} }
}; };
const router = useRouter();
const handleCompose = () => {
savePendingMailto({
to: [formatRecipient(contactName, email)],
cc: [],
bcc: [],
subject: "",
body: "",
});
notifyPendingMailto();
router.push("/");
handleClose();
};
return ( return (
<> <>
<button <button
@@ -210,14 +228,14 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
<Copy className="w-3.5 h-3.5" /> <Copy className="w-3.5 h-3.5" />
Copy Copy
</button> </button>
<a <button
href={`mailto:${email}`} onClick={handleCompose}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors" className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors"
title="Send email" title="Send email"
> >
<Send className="w-3.5 h-3.5" /> <Send className="w-3.5 h-3.5" />
Email Email
</a> </button>
{onViewContact && ( {onViewContact && (
<button <button
onClick={handleViewContact} onClick={handleViewContact}
+7
View File
@@ -71,6 +71,13 @@ const FIRST_PARTY_PLUGINS: FirstPartyPlugin[] = [
// feature - the former in-host native pipeline is gone - so the long-standing // feature - the former in-host native pipeline is gone - so the long-standing
// `smimeEnabled` policy gate now controls this plugin. // `smimeEnabled` policy gate now controls this plugin.
{ id: 'smime', gate: 'smimeEnabled', forceEnable: true }, { id: 'smime', gate: 'smimeEnabled', forceEnable: true },
// VNCdirectory deep-link. Users are managed in the directory, not the
// webmail; this plugin adds a "User management" Settings entry that opens
// the directory's user list. Force-enabled so it is always present.
{ id: 'manage-users', gate: 'manageUsersEnabled', forceEnable: true },
// SRC video meetings (VNCtalk / Jitsi). "Start a meeting" asks the server
// for a signed JWT and opens the room in meet.src-advisory.com.
{ id: 'jitsi-meet', gate: 'jitsiMeetEnabled', forceEnable: true },
]; ];
const ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; const ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
+4
View File
@@ -55,6 +55,8 @@ export interface FeatureGates {
calendarEnabled: boolean; calendarEnabled: boolean;
calendarTasksEnabled: boolean; calendarTasksEnabled: boolean;
smimeEnabled: boolean; smimeEnabled: boolean;
manageUsersEnabled: boolean;
jitsiMeetEnabled: boolean;
externalContentEnabled: boolean; externalContentEnabled: boolean;
debugModeEnabled: boolean; debugModeEnabled: boolean;
folderIconsEnabled: boolean; folderIconsEnabled: boolean;
@@ -90,6 +92,8 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
calendarEnabled: true, calendarEnabled: true,
calendarTasksEnabled: true, calendarTasksEnabled: true,
smimeEnabled: true, smimeEnabled: true,
manageUsersEnabled: true,
jitsiMeetEnabled: true,
externalContentEnabled: true, externalContentEnabled: true,
debugModeEnabled: true, debugModeEnabled: true,
folderIconsEnabled: true, folderIconsEnabled: true,
+17 -12
View File
@@ -1014,11 +1014,16 @@ body[data-theme-skin="builtin-vnclagoon"] .rounded-2xl.border.bg-background\\/80
// near-black stone-grey neutral. Dark variant brightens the red (#EF4444) on a // near-black stone-grey neutral. Dark variant brightens the red (#EF4444) on a
// warm near-black. Info stays blue so it never collides with the red accent. // warm near-black. Info stays blue so it never collides with the red accent.
const srcCSS = ` const srcCSS = `
@font-face { font-family: 'DM Sans'; font-style: normal; font-weight: 400; font-display: swap; src: url('/fonts/dmsans-400.woff2') format('woff2'); } @font-face { font-family: 'Inter'; font-style: normal; font-weight: 100 900; font-display: swap; src: url('/fonts/inter-var-latin.woff2') format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@font-face { font-family: 'DM Sans'; font-style: normal; font-weight: 500; font-display: swap; src: url('/fonts/dmsans-500.woff2') format('woff2'); } @font-face { font-family: 'Inter'; font-style: normal; font-weight: 100 900; font-display: swap; src: url('/fonts/inter-var-latin-ext.woff2') format('woff2'); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
@font-face { font-family: 'DM Sans'; font-style: normal; font-weight: 700; font-display: swap; src: url('/fonts/dmsans-700.woff2') format('woff2'); } @font-face { font-family: 'Spectral'; font-style: normal; font-weight: 400; font-display: swap; src: url('/fonts/spectral-400-latin.woff2') format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@font-face { font-family: 'Syne'; font-style: normal; font-weight: 700; font-display: swap; src: url('/fonts/syne-700.woff2') format('woff2'); } @font-face { font-family: 'Spectral'; font-style: normal; font-weight: 400; font-display: swap; src: url('/fonts/spectral-400-latin-ext.woff2') format('woff2'); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
@font-face { font-family: 'Syne'; font-style: normal; font-weight: 800; font-display: swap; src: url('/fonts/syne-800.woff2') format('woff2'); } @font-face { font-family: 'Spectral'; font-style: normal; font-weight: 600; font-display: swap; src: url('/fonts/spectral-600-latin.woff2') format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@font-face { font-family: 'Spectral'; font-style: normal; font-weight: 600; font-display: swap; src: url('/fonts/spectral-600-latin-ext.woff2') format('woff2'); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
@font-face { font-family: 'Spectral'; font-style: normal; font-weight: 700; font-display: swap; src: url('/fonts/spectral-700-latin.woff2') format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@font-face { font-family: 'Spectral'; font-style: normal; font-weight: 700; font-display: swap; src: url('/fonts/spectral-700-latin-ext.woff2') format('woff2'); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
@font-face { font-family: 'Spectral'; font-style: normal; font-weight: 800; font-display: swap; src: url('/fonts/spectral-800-latin.woff2') format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@font-face { font-family: 'Spectral'; font-style: normal; font-weight: 800; font-display: swap; src: url('/fonts/spectral-800-latin-ext.woff2') format('woff2'); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
:root { :root {
--color-border: #e7e5e4; --color-border: #e7e5e4;
--color-input: #e7e5e4; --color-input: #e7e5e4;
@@ -1105,12 +1110,12 @@ const srcCSS = `
// All scoped under the skin body attribute so they detach cleanly on switch-off. // All scoped under the skin body attribute so they detach cleanly on switch-off.
const srcSkin = ` const srcSkin = `
body[data-theme-skin="builtin-src"] { body[data-theme-skin="builtin-src"] {
font-family: "DM Sans", system-ui, -apple-system, "Segoe UI", sans-serif; font-family: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
} }
body[data-theme-skin="builtin-src"] h1, body[data-theme-skin="builtin-src"] h1,
body[data-theme-skin="builtin-src"] h2, body[data-theme-skin="builtin-src"] h2,
body[data-theme-skin="builtin-src"] h3 { body[data-theme-skin="builtin-src"] h3 {
font-family: "Syne", "DM Sans", sans-serif; font-family: "Spectral", "Inter", Georgia, serif;
font-weight: 700; font-weight: 700;
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
@@ -1135,7 +1140,7 @@ body[data-theme-skin="builtin-src"] button:not(.rounded-full):not([role="switch"
} }
/* MD3 filled button — primary surface, M3 label-large, state layers */ /* MD3 filled button — primary surface, M3 label-large, state layers */
body[data-theme-skin="builtin-src"] .bg-primary.text-primary-foreground { body[data-theme-skin="builtin-src"] .bg-primary.text-primary-foreground:not(.rounded-full) {
border-radius: 20px !important; border-radius: 20px !important;
padding-inline: 24px !important; padding-inline: 24px !important;
min-height: 40px !important; min-height: 40px !important;
@@ -1146,20 +1151,20 @@ body[data-theme-skin="builtin-src"] .bg-primary.text-primary-foreground {
transition: box-shadow 200ms ease, filter 200ms ease; transition: box-shadow 200ms ease, filter 200ms ease;
} }
/* hover: M3 elevation 1 + 8 % on-primary state layer */ /* hover: M3 elevation 1 + 8 % on-primary state layer */
body[data-theme-skin="builtin-src"] .bg-primary.text-primary-foreground:hover { body[data-theme-skin="builtin-src"] .bg-primary.text-primary-foreground:not(.rounded-full):hover {
box-shadow: box-shadow:
0 1px 2px rgba(0, 0, 0, 0.30), 0 1px 2px rgba(0, 0, 0, 0.30),
0 2px 6px 2px rgba(0, 0, 0, 0.15) !important; 0 2px 6px 2px rgba(0, 0, 0, 0.15) !important;
filter: brightness(1.06); filter: brightness(1.06);
} }
/* focus: +12 % tint + M3 focus ring */ /* focus: +12 % tint + M3 focus ring */
body[data-theme-skin="builtin-src"] .bg-primary.text-primary-foreground:focus-visible { body[data-theme-skin="builtin-src"] .bg-primary.text-primary-foreground:not(.rounded-full):focus-visible {
filter: brightness(1.10) !important; filter: brightness(1.10) !important;
outline: 3px solid var(--color-ring) !important; outline: 3px solid var(--color-ring) !important;
outline-offset: 2px !important; outline-offset: 2px !important;
} }
/* pressed: +12 % darker, no shadow */ /* pressed: +12 % darker, no shadow */
body[data-theme-skin="builtin-src"] .bg-primary.text-primary-foreground:active { body[data-theme-skin="builtin-src"] .bg-primary.text-primary-foreground:not(.rounded-full):active {
box-shadow: none !important; box-shadow: none !important;
filter: brightness(0.94) !important; filter: brightness(0.94) !important;
} }
@@ -1346,7 +1351,7 @@ export const BUILTIN_THEMES: InstalledTheme[] = [
logoLightUrl: '/branding/SRC_Symbol.png', logoLightUrl: '/branding/SRC_Symbol.png',
logoDarkUrl: '/branding/SRC_Symbol.png', logoDarkUrl: '/branding/SRC_Symbol.png',
variants: ['light', 'dark'], variants: ['light', 'dark'],
typography: { fontSans: '"DM Sans", system-ui, -apple-system, "Segoe UI", sans-serif' }, typography: { fontSans: '"Inter", system-ui, -apple-system, "Segoe UI", sans-serif' },
enabled: true, enabled: true,
builtIn: true, builtIn: true,
}, },
+26 -10
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarParticipant, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { IJMAPClient } from "./client-interface"; import type { IJMAPClient } from "./client-interface";
import { toWildcardQuery } from "./search-utils"; import { toWildcardQuery } from "./search-utils";
@@ -3135,9 +3135,18 @@ export class JMAPClient implements IJMAPClient {
throw new Error('No drafts mailbox found'); throw new Error('No drafts mailbox found');
} }
// Find the organizer participant // Find the organizer participant. Stalwart may not echo `roles.owner`, so
// also fall back to the event-level organizerCalendarAddress, and derive
// participant emails from email / calendarAddress / sendTo.imip.
const participantEmail = (p: CalendarParticipant): string =>
p.email
|| p.calendarAddress?.replace(/^mailto:/i, '')
|| p.sendTo?.imip?.replace(/^mailto:/i, '')
|| '';
const organizerEntry = Object.values(event.participants).find(p => p.roles?.owner); const organizerEntry = Object.values(event.participants).find(p => p.roles?.owner);
const organizerEmail = organizerEntry?.email || organizerEntry?.sendTo?.imip?.replace('mailto:', '') || this.username; const organizerEmail = (organizerEntry ? participantEmail(organizerEntry) : '')
|| event.organizerCalendarAddress?.replace(/^mailto:/i, '')
|| this.username;
const organizerName = organizerEntry?.name || ''; const organizerName = organizerEntry?.name || '';
// Resolve identity // Resolve identity
@@ -3152,7 +3161,7 @@ export class JMAPClient implements IJMAPClient {
} }
// Collect attendee participants (non-organizer) // Collect attendee participants (non-organizer)
const attendees = Object.values(event.participants).filter(p => !p.roles?.owner); const attendees = Object.values(event.participants).filter(p => !p.roles?.owner && participantEmail(p) !== organizerEmail);
if (attendees.length === 0) return; if (attendees.length === 0) return;
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
@@ -3212,7 +3221,7 @@ export class JMAPClient implements IJMAPClient {
lines.push(`ORGANIZER${orgCn}:mailto:${organizerEmail}`); lines.push(`ORGANIZER${orgCn}:mailto:${organizerEmail}`);
for (const attendee of attendees) { for (const attendee of attendees) {
const email = attendee.email || attendee.sendTo?.imip?.replace('mailto:', ''); const email = participantEmail(attendee);
if (!email) continue; if (!email) continue;
const cn = attendee.name ? `;CN=${icsParamValue(attendee.name)}` : ''; const cn = attendee.name ? `;CN=${icsParamValue(attendee.name)}` : '';
const partstat = attendee.participationStatus const partstat = attendee.participationStatus
@@ -3228,7 +3237,7 @@ export class JMAPClient implements IJMAPClient {
const subject = `Invitation: ${event.title || 'Event'}`; const subject = `Invitation: ${event.title || 'Event'}`;
const toAddresses = attendees const toAddresses = attendees
.map(a => ({ name: a.name || undefined, email: a.email || a.sendTo?.imip?.replace('mailto:', '') || '' })) .map(a => ({ name: a.name || undefined, email: participantEmail(a) }))
.filter(a => a.email); .filter(a => a.email);
if (toAddresses.length === 0) return; if (toAddresses.length === 0) return;
@@ -3313,8 +3322,15 @@ export class JMAPClient implements IJMAPClient {
throw new Error('No drafts mailbox found'); throw new Error('No drafts mailbox found');
} }
const participantEmail = (p: CalendarParticipant): string =>
p.email
|| p.calendarAddress?.replace(/^mailto:/i, '')
|| p.sendTo?.imip?.replace(/^mailto:/i, '')
|| '';
const organizerEntry = Object.values(event.participants).find(p => p.roles?.owner); const organizerEntry = Object.values(event.participants).find(p => p.roles?.owner);
const organizerEmail = organizerEntry?.email || organizerEntry?.sendTo?.imip?.replace('mailto:', '') || this.username; const organizerEmail = (organizerEntry ? participantEmail(organizerEntry) : '')
|| event.organizerCalendarAddress?.replace(/^mailto:/i, '')
|| this.username;
const organizerName = organizerEntry?.name || ''; const organizerName = organizerEntry?.name || '';
const identityResponse = await this.request([ const identityResponse = await this.request([
@@ -3327,7 +3343,7 @@ export class JMAPClient implements IJMAPClient {
identityId = match?.id || identities[0]?.id || this.accountId; identityId = match?.id || identities[0]?.id || this.accountId;
} }
const attendees = Object.values(event.participants).filter(p => !p.roles?.owner); const attendees = Object.values(event.participants).filter(p => !p.roles?.owner && participantEmail(p) !== organizerEmail);
if (attendees.length === 0) return; if (attendees.length === 0) return;
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
@@ -3370,7 +3386,7 @@ export class JMAPClient implements IJMAPClient {
lines.push(`ORGANIZER${orgCn}:mailto:${organizerEmail}`); lines.push(`ORGANIZER${orgCn}:mailto:${organizerEmail}`);
for (const attendee of attendees) { for (const attendee of attendees) {
const email = attendee.email || attendee.sendTo?.imip?.replace('mailto:', ''); const email = participantEmail(attendee);
if (!email) continue; if (!email) continue;
const cn = attendee.name ? `;CN=${icsParamValue(attendee.name)}` : ''; const cn = attendee.name ? `;CN=${icsParamValue(attendee.name)}` : '';
lines.push(`ATTENDEE${cn}:mailto:${email}`); lines.push(`ATTENDEE${cn}:mailto:${email}`);
@@ -3382,7 +3398,7 @@ export class JMAPClient implements IJMAPClient {
const subject = `Cancelled: ${event.title || 'Event'}`; const subject = `Cancelled: ${event.title || 'Event'}`;
const toAddresses = attendees const toAddresses = attendees
.map(a => ({ name: a.name || undefined, email: a.email || a.sendTo?.imip?.replace('mailto:', '') || '' })) .map(a => ({ name: a.name || undefined, email: participantEmail(a) }))
.filter(a => a.email); .filter(a => a.email);
if (toAddresses.length === 0) return; if (toAddresses.length === 0) return;
+11
View File
@@ -62,6 +62,7 @@
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.59.0", "@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0", "@typescript-eslint/parser": "^8.59.0",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
@@ -4730,6 +4731,16 @@
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.0", "version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz",
+1
View File
@@ -101,6 +101,7 @@
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.59.0", "@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0", "@typescript-eslint/parser": "^8.59.0",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+35 -9
View File
@@ -487,9 +487,16 @@ export const useCalendarStore = create<CalendarStore>()(
} }
set((state) => ({ events: [...state.events, mappedCreated] })); set((state) => ({ events: [...state.events, mappedCreated] }));
// Invitation emails are sent by the server: `sendSchedulingMessages` // Send invitation emails (iTIP REQUEST) to participants. Stalwart
// on CalendarEvent/set makes Stalwart queue the iTIP REQUEST itself. // 0.16 does not reliably queue these server-side via
// Sending a client-side iMIP copy here produced duplicate emails. // `sendSchedulingMessages`, so fall back to a client-side iMIP send.
if (sendSchedulingMessages && created.participants) {
try {
await client.sendImipInvitation(created);
} catch (e) {
debug.error('Failed to send invitation emails:', e);
}
}
return mappedCreated; return mappedCreated;
} catch (error) { } catch (error) {
debug.error('Failed to create event:', error); debug.error('Failed to create event:', error);
@@ -559,9 +566,23 @@ export const useCalendarStore = create<CalendarStore>()(
return merged; return merged;
}), }),
})); }));
// Update emails (iTIP REQUEST/REPLY) are sent by the server via the // Send invitation emails (iTIP REQUEST) when scheduling is requested.
// `sendSchedulingMessages` argument already passed above - a manual if (sendSchedulingMessages) {
// iMIP send here produced duplicate emails. const mergedParticipants = cleanUpdates.participants ?? storeEvent?.participants;
if (mergedParticipants) {
const eventForInvitation = {
...(storeEvent ?? {}),
...cleanUpdates,
id: realId,
participants: mergedParticipants,
} as CalendarEvent;
try {
await client.sendImipInvitation(eventForInvitation);
} catch (e) {
debug.error('Failed to send invitation emails:', e);
}
}
}
} catch (error) { } catch (error) {
debug.error('Failed to update event:', error); debug.error('Failed to update event:', error);
if (isNetworkError(error)) { if (isNetworkError(error)) {
@@ -808,9 +829,6 @@ export const useCalendarStore = create<CalendarStore>()(
try { try {
// Resolve shared event IDs and client-side expanded occurrence IDs // Resolve shared event IDs and client-side expanded occurrence IDs
client = resolveAccountClient(client, storeEvent?.localAccountId); client = resolveAccountClient(client, storeEvent?.localAccountId);
// Cancellation emails (iTIP CANCEL) are sent by the server via the
// `sendSchedulingMessages` argument on the destroy below - a manual
// iMIP send here produced duplicate emails.
debug.log('calendar', 'Calendar deleteEvent', { debug.log('calendar', 'Calendar deleteEvent', {
storeId: id, storeId: id,
realId, realId,
@@ -823,6 +841,14 @@ export const useCalendarStore = create<CalendarStore>()(
events: state.events.filter(e => e.id !== id), events: state.events.filter(e => e.id !== id),
selectedEventId: state.selectedEventId === id ? null : state.selectedEventId, selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,
})); }));
// Send cancellation emails (iTIP CANCEL) to participants.
if (sendSchedulingMessages && storeEvent?.participants) {
try {
await client.sendImipCancellation(storeEvent);
} catch (e) {
debug.error('Failed to send cancellation emails:', e);
}
}
} catch (error) { } catch (error) {
debug.error('Failed to delete event:', error); debug.error('Failed to delete event:', error);
if (isNetworkError(error)) { if (isNetworkError(error)) {
+8 -2
View File
@@ -490,7 +490,7 @@ const DEFAULT_SETTINGS = {
rtlEditingSupport: false, rtlEditingSupport: false,
subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER, subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER,
sendDelaySeconds: 0 as SendDelaySeconds, sendDelaySeconds: 0 as SendDelaySeconds,
signaturePosition: 'below_quote' as SignaturePosition, signaturePosition: 'above_quote' as SignaturePosition,
signatureSeparatorEnabled: true, signatureSeparatorEnabled: true,
requestReadReceiptDefault: false, requestReadReceiptDefault: false,
readReceiptResponse: 'ask' as ReadReceiptResponse, readReceiptResponse: 'ask' as ReadReceiptResponse,
@@ -998,7 +998,7 @@ export const useSettingsStore = create<SettingsState>()(
}), }),
{ {
name: 'settings-storage', name: 'settings-storage',
version: 7, version: 8,
migrate: migrateSettings, migrate: migrateSettings,
onRehydrateStorage: () => { onRehydrateStorage: () => {
return (state) => { return (state) => {
@@ -1085,6 +1085,12 @@ export function migrateSettings(persisted: unknown, version: number): SettingsSt
if (version < 6 || !isPlainRecord(state.preferredIdentityIds)) { if (version < 6 || !isPlainRecord(state.preferredIdentityIds)) {
state.preferredIdentityIds = {}; state.preferredIdentityIds = {};
} }
// v8: reply/forward signatures now sit above the quoted message by
// default (previously below). Migrate any persisted value so existing
// accounts pick up the new default.
if (version < 8) {
state.signaturePosition = 'above_quote';
}
return state as unknown as SettingsState; return state as unknown as SettingsState;
} }
+14
View File
@@ -0,0 +1,14 @@
{
"id": "jitsi-meet",
"name": "Video meetings",
"version": "1.0.0",
"author": "SRC Advisory",
"description": "SRC video meetings (VNCtalk / Jitsi). Start a meeting from Settings: the plugin asks the server for a signed JWT and opens the room in meet.src-advisory.com.",
"type": "ui-extension",
"tier": "privileged",
"permissions": [
"ui:settings-section"
],
"entrypoint": "index.js",
"minAppVersion": "1.7.6"
}
+481
View File
@@ -0,0 +1,481 @@
{
"name": "bulwark-plugin-jitsi-meet",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-plugin-jitsi-meet",
"version": "1.0.0",
"devDependencies": {
"esbuild": "^0.24.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2",
"@esbuild/android-arm": "0.24.2",
"@esbuild/android-arm64": "0.24.2",
"@esbuild/android-x64": "0.24.2",
"@esbuild/darwin-arm64": "0.24.2",
"@esbuild/darwin-x64": "0.24.2",
"@esbuild/freebsd-arm64": "0.24.2",
"@esbuild/freebsd-x64": "0.24.2",
"@esbuild/linux-arm": "0.24.2",
"@esbuild/linux-arm64": "0.24.2",
"@esbuild/linux-ia32": "0.24.2",
"@esbuild/linux-loong64": "0.24.2",
"@esbuild/linux-mips64el": "0.24.2",
"@esbuild/linux-ppc64": "0.24.2",
"@esbuild/linux-riscv64": "0.24.2",
"@esbuild/linux-s390x": "0.24.2",
"@esbuild/linux-x64": "0.24.2",
"@esbuild/netbsd-arm64": "0.24.2",
"@esbuild/netbsd-x64": "0.24.2",
"@esbuild/openbsd-arm64": "0.24.2",
"@esbuild/openbsd-x64": "0.24.2",
"@esbuild/sunos-x64": "0.24.2",
"@esbuild/win32-arm64": "0.24.2",
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "bulwark-plugin-jitsi-meet",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "esbuild src/index.js --bundle --format=cjs --platform=browser --charset=utf8 --outfile=dist/index.js --external:react --external:react-dom --external:react-dom/client --external:react/jsx-runtime --external:@plugin-host",
"dev": "npm run build -- --watch",
"package": "npm run build && cp manifest.json dist/ && cd dist && zip -X ../jitsi-meet.zip manifest.json index.js"
},
"devDependencies": {
"esbuild": "^0.24.0"
}
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Video meetings — SRC Advisory webmail plugin (VNCtalk / Jitsi).
*
* Adds a "Video meeting" entry to Settings. "Start a meeting" generates a room
* name, asks the webmail server for a signed JWT (the server holds the shared
* JITSI_APP_SECRET — it never reaches the browser), then opens the room in
* meet.src-advisory.com with the token as a query param.
*/
const host = require('@plugin-host');
const React = require('react');
const h = React.createElement;
const { useState } = React;
const card = {
border: '1px solid var(--color-border, #e2e8f0)',
borderRadius: '8px',
padding: '16px',
background: 'var(--color-card, #fff)',
color: 'var(--color-foreground, #0f172a)',
maxWidth: '720px',
};
const btnPrimary = {
font: 'inherit',
padding: '8px 14px',
borderRadius: '6px',
border: '1px solid var(--color-primary, #2563eb)',
background: 'var(--color-primary, #2563eb)',
color: '#fff',
cursor: 'pointer',
};
const input = {
font: 'inherit',
padding: '6px 8px',
borderRadius: '6px',
border: '1px solid var(--color-input, #cbd5e1)',
background: 'var(--color-background, #fff)',
color: 'var(--color-foreground, #0f172a)',
marginRight: '8px',
};
function generateRoomName() {
const suffix = Math.random().toString(36).slice(2, 10);
return `meeting-${suffix}`;
}
function SettingsSection() {
const [busy, setBusy] = useState(false);
const [joinRoom, setJoinRoom] = useState('');
async function openRoom(room) {
setBusy(true);
try {
const resp = await fetch('/api/jitsi/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ room }),
});
const data = await resp.json();
if (!resp.ok || !data.token) {
throw new Error(data.error || 'could not obtain meeting token');
}
const url = `${data.url}?jwt=${encodeURIComponent(data.token)}`;
try { host.ui.openExternalUrl(url); }
catch { window.open(url, '_blank', 'noopener,noreferrer'); }
} catch (err) {
const msg = err && err.message ? err.message : String(err);
try { host.toast.error(`Could not start meeting: ${msg}`); } catch { /* ignore */ }
} finally {
setBusy(false);
}
}
return h('div', { style: card },
h('div', { style: { fontWeight: 600, marginBottom: '6px' } }, 'Video meeting'),
h('div', { style: { fontSize: '13px', lineHeight: 1.5, marginBottom: '14px', color: 'var(--color-muted-foreground, #64748b)' } },
'Start or join a SRC video meeting. The room is opened in meet.src-advisory.com.'),
h('div', { style: { display: 'flex', alignItems: 'center', marginBottom: '10px' } },
h('button', { type: 'button', style: btnPrimary, disabled: busy, onClick: () => openRoom(generateRoomName()) },
busy ? 'Starting…' : 'Start a meeting →'),
),
h('div', { style: { display: 'flex', alignItems: 'center' } },
h('input', {
type: 'text',
style: input,
placeholder: 'Room name',
value: joinRoom,
onChange: (e) => setJoinRoom(e.target.value),
}),
h('button', { type: 'button', style: btnPrimary, disabled: busy || !joinRoom.trim(), onClick: () => openRoom(joinRoom.trim()) },
'Join'),
),
);
}
export const slots = {
'settings-section': { component: SettingsSection, order: 90 },
};
export async function activate(api) {
api.log.info('jitsi-meet plugin activated');
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "manage-users",
"name": "Manage users",
"version": "1.0.0",
"author": "SRC Advisory",
"description": "Deep-link to the VNCdirectory: users are created and assigned to SRCmail in the directory, not in the webmail. Adds a User management entry to Settings.",
"type": "ui-extension",
"tier": "privileged",
"permissions": [
"ui:settings-section"
],
"entrypoint": "index.js",
"minAppVersion": "1.7.6"
}
+481
View File
@@ -0,0 +1,481 @@
{
"name": "bulwark-plugin-manage-users",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-plugin-manage-users",
"version": "1.0.0",
"devDependencies": {
"esbuild": "^0.24.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2",
"@esbuild/android-arm": "0.24.2",
"@esbuild/android-arm64": "0.24.2",
"@esbuild/android-x64": "0.24.2",
"@esbuild/darwin-arm64": "0.24.2",
"@esbuild/darwin-x64": "0.24.2",
"@esbuild/freebsd-arm64": "0.24.2",
"@esbuild/freebsd-x64": "0.24.2",
"@esbuild/linux-arm": "0.24.2",
"@esbuild/linux-arm64": "0.24.2",
"@esbuild/linux-ia32": "0.24.2",
"@esbuild/linux-loong64": "0.24.2",
"@esbuild/linux-mips64el": "0.24.2",
"@esbuild/linux-ppc64": "0.24.2",
"@esbuild/linux-riscv64": "0.24.2",
"@esbuild/linux-s390x": "0.24.2",
"@esbuild/linux-x64": "0.24.2",
"@esbuild/netbsd-arm64": "0.24.2",
"@esbuild/netbsd-x64": "0.24.2",
"@esbuild/openbsd-arm64": "0.24.2",
"@esbuild/openbsd-x64": "0.24.2",
"@esbuild/sunos-x64": "0.24.2",
"@esbuild/win32-arm64": "0.24.2",
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "bulwark-plugin-manage-users",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "esbuild src/index.js --bundle --format=cjs --platform=browser --charset=utf8 --outfile=dist/index.js --external:react --external:react-dom --external:react-dom/client --external:react/jsx-runtime --external:@plugin-host",
"dev": "npm run build -- --watch",
"package": "npm run build && cp manifest.json dist/ && cd dist && zip -X ../manage-users.zip manifest.json index.js"
},
"devDependencies": {
"esbuild": "^0.24.0"
}
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Manage users — SRC Advisory webmail plugin.
*
* Adds a first-class "User management" entry to the Settings page. Users are
* the VNCdirectory's concern (the SRC directory), so this plugin is a thin
* deep-link: it explains where users live and opens the directory's user list
* in a new tab. It deliberately does NOT try to manage users itself.
*/
const host = require('@plugin-host');
const React = require('react');
const h = React.createElement;
const MANAGE_USERS_URL = 'https://vncdirectory.src-advisory.com/users';
const card = {
border: '1px solid var(--color-border, #e2e8f0)',
borderRadius: '8px',
padding: '16px',
background: 'var(--color-card, #fff)',
color: 'var(--color-foreground, #0f172a)',
maxWidth: '720px',
};
const btnPrimary = {
font: 'inherit',
padding: '8px 14px',
borderRadius: '6px',
border: '1px solid var(--color-primary, #2563eb)',
background: 'var(--color-primary, #2563eb)',
color: '#fff',
cursor: 'pointer',
};
function SettingsSection() {
return h('div', { style: card },
h('div', { style: { fontWeight: 600, marginBottom: '6px' } }, 'User management'),
h('div', { style: { fontSize: '13px', lineHeight: 1.5, marginBottom: '14px', color: 'var(--color-muted-foreground, #64748b)' } },
'Users are created and managed in the VNCdirectory, not in SRCmail. ' +
'Add a user there and assign them to the SRC organization to give them a mailbox automatically.'),
h('button', {
type: 'button',
style: btnPrimary,
onClick: () => {
try { host.ui.openExternalUrl(MANAGE_USERS_URL); } catch (err) {
// Fallback if the sandbox blocks window.open: navigate a normal link.
window.open(MANAGE_USERS_URL, '_blank', 'noopener,noreferrer');
}
},
}, 'Open VNCdirectory →'),
);
}
export const slots = {
'settings-section': { component: SettingsSection, order: 100 },
};
export async function activate(api) {
api.log.info('manage-users plugin activated');
}