Add Postbox message authoring and concurrency

This commit is contained in:
2026-07-31 18:40:39 +02:00
parent 0b6ebc3c74
commit dc6f81dd33
12 changed files with 1392 additions and 68 deletions
+78 -20
View File
@@ -40,6 +40,8 @@ export type PostboxDirectoryItem = {
holder_count: number;
vacant: boolean;
access?: PostboxAccessDecision | null;
resource_revision: number;
etag: string;
};
export type PostboxParticipant = {
@@ -78,6 +80,8 @@ export type PostboxMessage = {
producer_module?: string | null;
producer_resource_type?: string | null;
producer_resource_id?: string | null;
in_reply_to_message_id?: string | null;
replaces_message_id?: string | null;
encryption_profile: string;
key_epoch: number;
ciphertext_ref?: string | null;
@@ -92,6 +96,8 @@ export type PostboxGrouping = {
name: string;
is_default: boolean;
postbox_ids: string[];
resource_revision: number;
etag: string;
created_at: string;
updated_at: string;
};
@@ -188,6 +194,8 @@ export type PostboxTemplate = {
description?: string | null;
status: string;
current_revision: number;
resource_revision: number;
etag: string;
published_revision_id?: string | null;
revisions: PostboxTemplateRevision[];
created_at: string;
@@ -221,6 +229,21 @@ export type PostboxExactCreatePayload = {
classification: string;
};
export type PostboxMessageAuthoringPayload = {
idempotency_key: string;
subject: string;
body_text?: string | null;
classification: string;
participants: PostboxParticipant[];
attachments: PostboxAttachment[];
metadata: Record<string, unknown>;
};
type PostboxGroupingPayload = Pick<
PostboxGrouping,
"name" | "is_default" | "postbox_ids"
>;
export async function listPostboxes(settings: ApiSettings): Promise<PostboxDirectoryItem[]> {
const response = await apiFetch<{ postboxes: PostboxDirectoryItem[] }>(
settings,
@@ -271,6 +294,29 @@ export function markPostboxMessage(
);
}
export function createPostboxMessage(
settings: ApiSettings,
postboxId: string,
payload: PostboxMessageAuthoringPayload
): Promise<PostboxMessage> {
return apiPostJson(settings, "/api/v1/postbox/messages", {
...payload,
postbox_id: postboxId
});
}
export function replyToPostboxMessage(
settings: ApiSettings,
messageId: string,
payload: PostboxMessageAuthoringPayload
): Promise<PostboxMessage> {
return apiPostJson(
settings,
`/api/v1/postbox/messages/${encodeURIComponent(messageId)}/replies`,
payload
);
}
export async function listPostboxGroupings(settings: ApiSettings): Promise<PostboxGrouping[]> {
const response = await apiFetch<{ groupings: PostboxGrouping[] }>(
settings,
@@ -281,34 +327,39 @@ export async function listPostboxGroupings(settings: ApiSettings): Promise<Postb
export function createPostboxGrouping(
settings: ApiSettings,
payload: Omit<PostboxGrouping, "id" | "created_at" | "updated_at">
payload: PostboxGroupingPayload
): Promise<PostboxGrouping> {
return apiPostJson(settings, "/api/v1/postbox/groupings", payload);
}
export function updatePostboxGrouping(
settings: ApiSettings,
groupingId: string,
payload: Omit<PostboxGrouping, "id" | "created_at" | "updated_at">
grouping: PostboxGrouping,
payload: PostboxGroupingPayload
): Promise<PostboxGrouping> {
return apiFetch(
settings,
`/api/v1/postbox/groupings/${encodeURIComponent(groupingId)}`,
`/api/v1/postbox/groupings/${encodeURIComponent(grouping.id)}`,
{
method: "PUT",
body: JSON.stringify(payload)
headers: { "If-Match": grouping.etag },
body: JSON.stringify({ ...payload, base_revision: grouping.resource_revision })
}
);
}
export function deletePostboxGrouping(
settings: ApiSettings,
groupingId: string
grouping: PostboxGrouping
): Promise<void> {
return apiFetch(
settings,
`/api/v1/postbox/groupings/${encodeURIComponent(groupingId)}`,
{ method: "DELETE" }
`/api/v1/postbox/groupings/${encodeURIComponent(grouping.id)}`,
{
method: "DELETE",
headers: { "If-Match": grouping.etag },
body: JSON.stringify({ base_revision: grouping.resource_revision })
}
);
}
@@ -338,12 +389,16 @@ export function createExactPostbox(
export function archivePostbox(
settings: ApiSettings,
postboxId: string
postbox: PostboxDirectoryItem
): Promise<PostboxDirectoryItem> {
return apiFetch(
settings,
`/api/v1/postbox/admin/postboxes/${encodeURIComponent(postboxId)}`,
{ method: "DELETE" }
`/api/v1/postbox/admin/postboxes/${encodeURIComponent(postbox.id)}`,
{
method: "DELETE",
headers: { "If-Match": postbox.etag },
body: JSON.stringify({ base_revision: postbox.resource_revision })
}
);
}
@@ -364,36 +419,39 @@ export function createPostboxTemplate(
export function revisePostboxTemplate(
settings: ApiSettings,
templateId: string,
template: PostboxTemplate,
payload: PostboxTemplateRevisionPayload
): Promise<PostboxTemplate> {
return apiPostJson(
settings,
`/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/revisions`,
payload
`/api/v1/postbox/admin/templates/${encodeURIComponent(template.id)}/revisions`,
{ ...payload, base_revision: template.resource_revision },
{ headers: { "If-Match": template.etag } }
);
}
export function publishPostboxTemplate(
settings: ApiSettings,
templateId: string,
template: PostboxTemplate,
revision?: number
): Promise<PostboxTemplate> {
return apiPostJson(
settings,
`/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/publish`,
{ revision: revision ?? null }
`/api/v1/postbox/admin/templates/${encodeURIComponent(template.id)}/publish`,
{ revision: revision ?? null, base_revision: template.resource_revision },
{ headers: { "If-Match": template.etag } }
);
}
export function retirePostboxTemplate(
settings: ApiSettings,
templateId: string
template: PostboxTemplate
): Promise<PostboxTemplate> {
return apiPostJson(
settings,
`/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/retire`,
{}
`/api/v1/postbox/admin/templates/${encodeURIComponent(template.id)}/retire`,
{ base_revision: template.resource_revision },
{ headers: { "If-Match": template.etag } }
);
}
@@ -236,9 +236,13 @@ export default function PostboxAdminPanel({
setSuccess("");
try {
if (templateDraft.templateId) {
const currentTemplate = templates.find(
(item) => item.id === templateDraft.templateId
);
if (!currentTemplate) throw new Error("The template is no longer available.");
await revisePostboxTemplate(
settings,
templateDraft.templateId,
currentTemplate,
revisionPayload(templateDraft)
);
setSuccess("A new immutable template revision was created.");
@@ -268,7 +272,7 @@ export default function PostboxAdminPanel({
try {
await publishPostboxTemplate(
settings,
selectedTemplate.id,
selectedTemplate,
selectedTemplate.current_revision
);
setSuccess(`Published revision ${selectedTemplate.current_revision}.`);
@@ -286,7 +290,7 @@ export default function PostboxAdminPanel({
setError("");
setSuccess("");
try {
await retirePostboxTemplate(settings, selectedTemplate.id);
await retirePostboxTemplate(settings, selectedTemplate);
setSuccess("Postbox template retired. Existing addresses remain durable.");
await load();
} catch (actionError) {
@@ -365,7 +369,7 @@ export default function PostboxAdminPanel({
setError("");
setSuccess("");
try {
await archivePostbox(settings, archiveTarget.id);
await archivePostbox(settings, archiveTarget);
setSuccess("Postbox archived. Messages and delivery evidence were retained.");
setArchiveTarget(null);
await load();
+302 -28
View File
@@ -10,7 +10,9 @@ import {
Pencil,
Plus,
RefreshCw,
Reply,
Search,
Send,
Trash2,
UserRoundCheck,
X
@@ -34,12 +36,14 @@ import {
} from "@govoplan/core-webui";
import {
createPostboxGrouping,
createPostboxMessage,
deletePostboxGrouping,
getPostboxMessage,
listPostboxGroupings,
listPostboxMessages,
listPostboxes,
markPostboxMessage,
replyToPostboxMessage,
updatePostboxGrouping,
type PostboxDirectoryItem,
type PostboxGrouping,
@@ -56,6 +60,14 @@ type GroupingDraft = {
type MessageStateFilter = "all" | "unread" | "read" | "acknowledged";
type MessageDraft = {
postbox_id: string;
subject: string;
body_text: string;
classification: string;
recipients: string;
};
const emptyGrouping = (): GroupingDraft => ({
id: "",
name: "",
@@ -63,6 +75,14 @@ const emptyGrouping = (): GroupingDraft => ({
postbox_ids: []
});
const emptyMessageDraft = (): MessageDraft => ({
postbox_id: "",
subject: "",
body_text: "",
classification: "internal",
recipients: ""
});
export default function PostboxPage({
settings,
auth
@@ -94,8 +114,13 @@ export default function PostboxPage({
const [error, setError] = useState("");
const [groupingDialogOpen, setGroupingDialogOpen] = useState(false);
const [groupingDraft, setGroupingDraft] = useState<GroupingDraft>(emptyGrouping);
const [messageDialogOpen, setMessageDialogOpen] = useState(false);
const [replyParent, setReplyParent] = useState<PostboxMessage | null>(null);
const [messageDraft, setMessageDraft] = useState<MessageDraft>(emptyMessageDraft);
const canAcknowledge = hasScope(auth, "postbox:message:acknowledge");
const canSend = hasScope(auth, "postbox:message:write");
const canReply = hasScope(auth, "postbox:message:reply");
const selectedPostbox = useMemo(
() => postboxes.find((postbox) => postbox.id === selectedPostboxId) ?? null,
[postboxes, selectedPostboxId]
@@ -335,8 +360,9 @@ export default function PostboxPage({
postbox_ids: groupingDraft.postbox_ids
};
try {
const saved = groupingDraft.id
? await updatePostboxGrouping(settings, groupingDraft.id, payload)
const existing = groupings.find((item) => item.id === groupingDraft.id);
const saved = existing
? await updatePostboxGrouping(settings, existing, payload)
: await createPostboxGrouping(settings, payload);
await loadDirectory();
setSelectedScope(saved.id);
@@ -354,7 +380,9 @@ export default function PostboxPage({
setBusy(true);
setError("");
try {
await deletePostboxGrouping(settings, groupingDraft.id);
const existing = groupings.find((item) => item.id === groupingDraft.id);
if (!existing) throw new Error("The grouping is no longer available.");
await deletePostboxGrouping(settings, existing);
setSelectedScope("all");
setSelectedPostboxId("");
setGroupingDialogOpen(false);
@@ -366,6 +394,86 @@ export default function PostboxPage({
}
}
function openCompose() {
const postbox = selectedPostbox ?? postboxes[0] ?? null;
if (!postbox) return;
setReplyParent(null);
setMessageDraft({
...emptyMessageDraft(),
postbox_id: postbox.id,
classification: postbox.classification
});
setMessageDialogOpen(true);
}
function openReply() {
if (!selectedMessage) return;
setReplyParent(selectedMessage);
setMessageDraft({
...emptyMessageDraft(),
postbox_id: selectedMessage.postbox_id,
subject: selectedMessage.subject.toLowerCase().startsWith("re:")
? selectedMessage.subject
: `Re: ${selectedMessage.subject}`,
classification: selectedMessage.classification
});
setMessageDialogOpen(true);
}
async function submitMessage() {
if (!messageDraft.postbox_id || !messageDraft.subject.trim()) return;
setBusy(true);
setError("");
const participants = messageDraft.recipients
.split(/[;,\n]/)
.map((address) => address.trim())
.filter(Boolean)
.map((address) => ({
kind: "to",
reference_type: "address",
address
}));
const payload = {
idempotency_key: crypto.randomUUID(),
subject: messageDraft.subject.trim(),
body_text: messageDraft.body_text || null,
classification: messageDraft.classification,
participants,
attachments: [],
metadata: {}
};
try {
const message = replyParent
? await replyToPostboxMessage(settings, replyParent.id, payload)
: await createPostboxMessage(
settings,
messageDraft.postbox_id,
payload
);
setMessageDialogOpen(false);
setReplyParent(null);
setSelectedPostboxId(message.postbox_id);
setSelectedScope("all");
setSelectedMessageId(message.id);
setSelectedMessage(message);
setPage(1);
const refreshed = await listPostboxMessages(
settings,
[message.postbox_id],
pageSize,
0,
messageQuery,
messageState
);
setMessages(refreshed.messages);
setTotal(refreshed.total);
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setBusy(false);
}
}
return (
<main className="workspace-data-page module-entry-page postbox-page">
<div className="postbox-shell">
@@ -472,12 +580,20 @@ export default function PostboxPage({
)}
<span className="postbox-total">{total}</span>
</div>
<IconButton
label="Refresh messages"
icon={<RefreshCw size={16} />}
onClick={() => void loadMessages()}
disabled={loadingMessages || busy}
/>
<div className="postbox-icon-actions">
<IconButton
label="New message"
icon={<Send size={16} />}
onClick={openCompose}
disabled={!canSend || !postboxes.length || busy}
/>
<IconButton
label="Refresh messages"
icon={<RefreshCw size={16} />}
onClick={() => void loadMessages()}
disabled={loadingMessages || busy}
/>
</div>
</div>
<div className="postbox-message-filters">
<div className="postbox-search-row">
@@ -615,25 +731,38 @@ export default function PostboxPage({
<MailOpen size={17} aria-hidden="true" />
<strong>{selectedMessage?.subject || "Message"}</strong>
</div>
<Button
onClick={() => void acknowledgeSelected()}
disabled={
!selectedMessage ||
selectedMessage.availability !== "available" ||
Boolean(selectedMessage.acknowledged_at) ||
busy
}
disabledReason={
!canAcknowledge
? "You cannot acknowledge Postbox messages."
: selectedMessage &&
selectedMessage.availability !== "available"
? "Withdrawn or expired messages cannot be acknowledged."
: undefined
}
>
<CheckCheck size={16} /> Acknowledge
</Button>
<div className="button-row compact-actions">
<Button
onClick={openReply}
disabled={
!canReply ||
!selectedMessage ||
selectedMessage.availability !== "available" ||
busy
}
>
<Reply size={16} /> Reply
</Button>
<Button
onClick={() => void acknowledgeSelected()}
disabled={
!selectedMessage ||
selectedMessage.availability !== "available" ||
Boolean(selectedMessage.acknowledged_at) ||
busy
}
disabledReason={
!canAcknowledge
? "You cannot acknowledge Postbox messages."
: selectedMessage &&
selectedMessage.availability !== "available"
? "Withdrawn or expired messages cannot be acknowledged."
: undefined
}
>
<CheckCheck size={16} /> Acknowledge
</Button>
</div>
</div>
{selectedMessage ? (
<MessageDetail
@@ -737,6 +866,119 @@ export default function PostboxPage({
))}
</fieldset>
</Dialog>
<Dialog
open={messageDialogOpen}
title={replyParent ? "Reply" : "New message"}
className="postbox-dialog postbox-message-dialog"
onClose={() => setMessageDialogOpen(false)}
closeDisabled={busy}
footer={
<div className="postbox-dialog-actions end">
<Button onClick={() => setMessageDialogOpen(false)} disabled={busy}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => void submitMessage()}
disabled={
busy ||
!messageDraft.postbox_id ||
!messageDraft.subject.trim()
}
>
<Send size={16} /> Send
</Button>
</div>
}
>
<div className="postbox-compose-grid">
<FormField label="Postbox">
<select
value={messageDraft.postbox_id}
disabled={Boolean(replyParent)}
onChange={(event) => {
const postbox = postboxes.find(
(item) => item.id === event.target.value
);
setMessageDraft((current) => ({
...current,
postbox_id: event.target.value,
classification: postbox?.classification ?? "internal"
}));
}}
>
{postboxes.map((postbox) => (
<option key={postbox.id} value={postbox.id}>
{postbox.name}
</option>
))}
</select>
</FormField>
<FormField label="Classification">
<select
value={messageDraft.classification}
onChange={(event) =>
setMessageDraft((current) => ({
...current,
classification: event.target.value
}))
}
>
{classificationOptions(
postboxes.find((item) => item.id === messageDraft.postbox_id)
?.classification ?? "internal",
replyParent?.classification
).map((classification) => (
<option key={classification} value={classification}>
{classificationLabel(classification)}
</option>
))}
</select>
</FormField>
<div className="postbox-compose-wide">
<FormField label="Recipients">
<input
value={messageDraft.recipients}
placeholder="Separate addresses with commas"
onChange={(event) =>
setMessageDraft((current) => ({
...current,
recipients: event.target.value
}))
}
/>
</FormField>
</div>
<div className="postbox-compose-wide">
<FormField label="Subject">
<input
value={messageDraft.subject}
onChange={(event) =>
setMessageDraft((current) => ({
...current,
subject: event.target.value
}))
}
/>
</FormField>
</div>
<div className="postbox-compose-wide">
<FormField label="Message">
<textarea
rows={10}
value={messageDraft.body_text}
onChange={(event) =>
setMessageDraft((current) => ({
...current,
body_text: event.target.value
}))
}
/>
</FormField>
</div>
</div>
</Dialog>
</main>
);
}
@@ -842,6 +1084,38 @@ function producerLabel(message: PostboxMessage): string {
return resource.length ? resource.join(" / ") : "Platform-native message";
}
const CLASSIFICATIONS = [
"public",
"internal",
"confidential",
"restricted"
] as const;
function classificationOptions(
postboxClassification: string,
minimumClassification?: string
): string[] {
const maximum = Math.max(
0,
CLASSIFICATIONS.indexOf(
postboxClassification as (typeof CLASSIFICATIONS)[number]
)
);
const minimum = minimumClassification
? Math.max(
0,
CLASSIFICATIONS.indexOf(
minimumClassification as (typeof CLASSIFICATIONS)[number]
)
)
: 0;
return CLASSIFICATIONS.slice(minimum, maximum + 1);
}
function classificationLabel(value: string): string {
return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
}
function formatDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
+29
View File
@@ -518,6 +518,31 @@
color: var(--muted);
}
.postbox-message-dialog {
width: min(720px, calc(100vw - 32px));
}
.postbox-compose-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(160px, 1fr);
gap: 12px;
}
.postbox-compose-wide {
grid-column: 1 / -1;
}
.postbox-compose-grid textarea {
width: 100%;
min-height: 180px;
resize: vertical;
}
.postbox-dialog-actions.end {
justify-content: flex-end;
gap: 8px;
}
.postbox-admin-page > .segmented-control {
margin-bottom: 14px;
}
@@ -667,4 +692,8 @@
align-items: flex-start;
flex-direction: column;
}
.postbox-compose-grid {
grid-template-columns: 1fr;
}
}