OnlyOffice-authored Correspondence letters — design spec
Status: APPROVED DESIGN (brainstormed 2026-07-24; decisions confirmed by user). Next step: implementation plan.
Directive (user): When the office module is enabled, Correspondence drafts/manages/renders letters through OnlyOffice's editor (docx), defaulting to docx authoring with a "quick text" escape hatch. Letterhead header/footer must be set up through OnlyOffice too (admins edit the letterhead template in the Word editor, native header/footer sections). When the module is off, the existing plain-text→HTML + Gotenberg path keeps working.
Corrections to the earlier queued stub (verified against code)
- No number stamping exists today.
AssignNumberWithLetterhead(correspondence/app/service.go:389) concatenatesheader_html + body_html + footer_htmland renders via Gotenberg Chromium — there is no{{number}}/{{date}}substitution anywhere in the pipeline. Today's rendered PDF contains the assigned number only if the author typed it. The docx path FIXES this via merge fields. - Letter compose is a plain Carbon TextArea (
NewLetterModal.tsx) wrapped to HTML client-side (htmlFromPlainText), not TipTap. Drafts are write-once — no update endpoint for body/subject exists; arejectedletter cannot be revised or resubmitted (dead-end state). - Letters have zero DMS linkage. The rendered PDF is a content-addressed blob referenced by
letters.content_hash; letters never become documents. The office machinery (handlers_office.go) is hard-wired to document IDs: token claims{purpose, doc, version, uid, exp}, all storage calls go throughs.dms.*.
Decisions (user-confirmed)
- D1 — Default docx, quick-text escape. With
officeEditEnabled(), the create modal defaults to docx authoring; a secondary "quick text" option keeps the plain TextArea →body_htmlpath. Office off ⇒ quick text only (today's behavior). - D2 — Letterheads are OnlyOffice-edited docx templates.
letterhead_templatesgains an optional docx artifact; admins edit it directly in OnlyOffice (kop in the native Word header section, footer likewise). Shipped starter templates have header/footer sections pre-wired. Upload/download of the .docx also supported. The legacyheader_html/footer_htmlfields stay (used by the HTML letter path and document finalize). - D3 — Merge fields v1:
{{NOMOR}},{{TANGGAL}}(assignment date, Indonesian long form, e.g.24 Juli 2026),{{PERIHAL}}(subject),{{SIFAT}}(classification). No{{LAMPIRAN}}— attachments can change after numbering, a stamped count would go stale. - D4 — Editability: a docx letter is editable in OnlyOffice while
draftorrejected, by the creator orcorrespondence.admin; view-only inin_review/approved(approvers must see exactly what they approve) and forever afternumbered. Consequence: submit-for-approval is additionally allowed fromrejected(one new transition; today submit is draft-only), otherwise "rejected is editable" has no outlet.
Architecture
Letter-owned docx + subject-aware office endpoints. Considered and REJECTED: backing each letter with a hidden DMS document (hidden docs would leak into every document surface — lists, search, gapless doc-ID sequence, retention, reports — and letter access rules (mayReadLetter) don't map onto document ACLs). Instead the letter (and the letterhead template) own a docx blob in the existing kernel.BlobStore, and the office HTTP layer learns two more subjects.
Data model — migration 00128 (renumber if a co-agent takes 00128 first)
ALTER TABLE letters
ADD COLUMN authoring text NOT NULL DEFAULT 'html' CHECK (authoring IN ('html','docx')),
ADD COLUMN draft_docx_hash text NULL, -- current editable docx (blob key)
ADD COLUMN draft_docx_rev integer NOT NULL DEFAULT 0, -- bumps on every OnlyOffice save; feeds editor cache key
ADD COLUMN final_docx_hash text NULL, -- merged (placeholder-substituted) docx frozen at numbering
ADD COLUMN letterhead_id uuid NULL REFERENCES letterhead_templates(id) ON DELETE SET NULL;
ALTER TABLE letterhead_templates
ADD COLUMN docx_hash text NULL, -- the docx template (blob key)
ADD COLUMN docx_rev integer NOT NULL DEFAULT 0;
Existing letters remain authoring='html' and behave exactly as today.
Office layer generalization (handlers_office.go)
- The HS256 office token gains a
subclaim:document(default when absent — backward compatible with in-flight tokens),letter,letterhead, plus avariantfor the letter-final case (below). OfficeContent/OfficeCallbackbranch onsub:document→s.dms.*exactly as today (zero behavior change).letter→ correspondence service:OpenDraftDocx(letterID)streams the blob atdraft_docx_hash; callback →SaveDraftDocx(ctx, letterID, uid, bytes).letter+variant=final→ streamsfinal_docx_hash(used only by the numbering conversion, read-only; never gets a callback token).letterhead→ letterhead service:OpenTemplateDocx(id)/SaveTemplateDocx(ctx, id, uid, bytes).- Callback hardening (F11 parity): callback tokens are minted only for editable sessions, AND the save handler re-checks editability at save time (letter still
draft/rejected, saver still creator/admin; letterhead saver still holdsletterhead.manage) — mirrors the document path'sEditingLockedre-check. A letter numbered while an editor was open ⇒ the late save is rejected. - Save semantics: blob
Put→ updatedraft_docx_hash/docx_hash+rev+1+updated_at. Only OnlyOffice callbackstatus == 2(all editors closed) persists, same as documents. Editor cache keys:letter_{id}_{rev},letterhead_{id}_{rev}(sanitized, < 128 chars).
New endpoints
| Endpoint | Auth | Purpose |
|---|---|---|
GET /api/v1/letters/{id}/office-config |
correspondence.read + mayReadLetter |
Editor config for a docx letter. editable per D4; callback token only when editable. |
GET /api/v1/letterheads/{id}/office-config |
letterhead.manage |
Editor config for a letterhead template. Lazy-seeds the starter docx (blob put + row update) when docx_hash is NULL, so "Edit in OnlyOffice" always works. Always editable. |
POST /api/v1/letterheads/{id}/docx |
letterhead.manage |
Multipart upload replacing the docx template (rev+1). Validated: zip opens, [Content_Types].xml + word/document.xml present, size cap 20 MB. |
GET /api/v1/letterheads/{id}/docx |
letterhead.manage |
Download the current template. |
POST /api/v1/letters (extended) |
unchanged | Accepts authoring (html default | docx) and letterhead_id. docx requires officeEditEnabled(); server seeds draft_docx_hash (see creation flow). |
GET /letters/{id} response additionally returns authoring and letterhead_id; GET /letterheads list items gain has_docx so the create modal can filter/flag docx-ready letterheads. Office content/callback stay on the existing public token routes.
Flows
Create (docx): POST /letters {authoring:"docx", letterhead_id?} → seed draft_docx_hash:
- letterhead chosen and it has a docx template → draft_docx_hash = letterhead.docx_hash (content-addressed ⇒ zero-copy; first save diverges naturally);
- letterhead chosen but no docx template → 422 correspondence.letterhead_no_docx (frontend prevents this by filtering on has_docx);
- no letterhead → seed from the embedded blank letter starter.
Frontend then navigates straight into the editor.
Edit: /letters/{id}/edit route mounts the OnlyOffice editor (same DocsAPI mount pattern as OfficeEditorView), config from the letter office-config endpoint. View mode doubles as the previewer for approvers and post-approval states — no separate preview pipeline.
Submit / approve: unchanged, except submit now allowed from rejected as well as draft (both authoring modes, uniformly).
Assign number (docx letter) — mirrors today's three-phase gapless design (service.go:362-378):
1. Tx A (unchanged): advisory scheme lock → NextSeqForPeriod → reserved allocation. Number string computable immediately via domain.FormatNumber.
2. Out of tx: load draft docx → merge-replace {{NOMOR}} {{TANGGAL}} {{PERIHAL}} {{SIFAT}} across word/document.xml + word/header*.xml + word/footer*.xml → blob-put merged docx and persist final_docx_hash (small standalone update, needed so the doc-server can fetch it) → convert to PDF:
- primary: OnlyOffice ConvertService — the core of convertViaOnlyOffice (handlers_publish.go:151) is extracted into a URL-based helper; the letter path feeds it a sub=letter, variant=final content-token URL and a per-attempt unique key;
- fallback: Gotenberg LibreOffice (s.office.ToPDF("letter.docx", merged)) — same fallback idiom as publish/preview. Consequence: docx letters remain numberable even if the office license lapses; only editing needs the module.
→ blob-put the PDF.
3. Tx B (unchanged): MarkAllocationAssigned + UpdateLetterNumbered. Any failure in phase 2 voids the allocation exactly like today's render-fail path (and clears final_docx_hash).
The AssignNumber modal for docx letters shows scheme only (letterhead was chosen at creation; no HTML injection happens). HTML letters keep the letterhead dropdown and the existing render path verbatim.
Letterhead admin: per-template actions — "Edit in OnlyOffice" (→ /letterheads/{id}/edit, lazy-seeded starter), "Upload .docx", "Download .docx". Helper text documents the four merge fields. The HTML header/footer editors remain for the HTML path.
Merge-field replacer (the one new algorithmic piece)
Pure Go, no new dependencies. Word splits literal text across arbitrary <w:r> runs (rsid/spellcheck), so naive string replace misses placeholders. Algorithm, per <w:p> paragraph in each target part: concatenate the paragraph's <w:t> texts → locate {{FIELD}} spans in the joined string → rewrite the runs so the replacement lands in the first affected run and the remainder of the span is emptied (preserving run properties/formatting of the first run). Substituted values are XML-escaped (subjects can contain <, &). Unknown/absent placeholders are left untouched; a letter with no placeholders renders fine (parity with today, which stamps nothing). Operates via archive/zip rewrite of the three part globs; all other zip entries are copied byte-for-byte. Corrupt output is caught by the convert step and voids the allocation — same failure envelope as a Gotenberg render error today.
Embedded starter assets (go:embed)
Two small docx files checked into the repo (built once with python-docx or handcrafted OOXML; they are plain zips):
- Letterhead starter: header section with sample kop text, footer section with sample footer, body containing the standard tata-naskah opening block (Nomor: {{NOMOR}}, Sifat: {{SIFAT}}, Perihal: {{PERIHAL}}, {{TANGGAL}}) as editable guidance. Guarantees header/footer sections exist so OnlyOffice's header/footer editing is immediately discoverable (D2).
- Blank letter starter: A4, empty header/footer sections wired, same opening block in the body (author can delete it).
No hard validation that uploaded templates contain header/footer parts (some layouts legitimately put the kop in the body); OnlyOffice can insert headers into any docx regardless.
Frontend
NewLetterModal: whenme.officeEdit— mode toggle defaulting to "Word editor (docx)" with fields type/classification/subject + letterhead dropdown (docx-ready ones flagged; "no letterhead" allowed), secondary "Quick text" restoring the TextArea. On docx create → navigate to/letters/{id}/edit. WithoutofficeEdit, exactly today's modal.- New routes (standalone, outside AppShell, mirroring
/documents/d/:docId/office):/letters/:id/editand/letterheads/:id/edit— one shared letter/letterhead office-editor view parameterized by config endpoint, title, and back-nav. LetterDetailModal: for docx letters thesrcDociframe is replaced by an "Open letter" button (label Edit/View per status); Download unchanged for numbered letters.AssignNumberModalhides the letterhead dropdown whenauthoring === 'docx'.- Letterhead admin page: docx template controls per D2.
- API layer: react-query hooks in
web/src/api/correspondence.ts(existingreq()raw-fetch pattern) + a letterhead office-config hook mirroringweb/src/api/office.ts. i18n in thecorrespondencefeature slice + core office strings, en + id.
Security notes
- Token scoping:
subclaim prevents cross-subject replay (a letter content token cannot fetch a document, and vice versa);variant=finaltokens are read-only by construction (no callback purpose ever minted for them). Claims stay{purpose, sub, doc, version/rev, uid, variant?, exp}— no blob hashes in tokens. mayReadLettergates letter office-config (confidential letters stay restricted);letterhead.managegates everything letterhead; save-time re-checks per F11 parity (above).- Merge substitution XML-escapes values — no template/XML injection via subject or classification.
- Upload validation (zip structure + size cap) on letterhead docx; SSRF posture unchanged (ConvertService fetches only our signed content URLs; Gotenberg deny-list from
686626aalready covers the LibreOffice route). - Flagged, out of scope (user radar): letter PDFs (
GET /letters/{id}/content) and letter attachments are served with no egress watermarking, unlike every document path.
Non-goals
- No migration of existing HTML letters; no metadata (subject/classification) editing; inbound intake untouched; approval/disposisi semantics unchanged (except the one submit-from-rejected transition); Gotenberg stays (HTML letters, office-off deployments, convert fallback, AI export); no letter egress watermarking (flagged above); no letter draft version history (single current draft + frozen final docx is the model — the numbered PDF is the immutable artifact).
Verification sketch (details in the plan)
Builds (go build ./... && go vet ./..., tsc && vite build); live e2e on the x056 demo (office module is on): lazy-seed a letterhead starter → edit kop in OnlyOffice → save; create docx letter from it → edit body → submit → approve → assign number → download PDF and assert the assigned number string appears in extracted text (pdfium via the stego venv); quick-text letter regression (create + number via HTML path); callback re-check (attempt save after numbering ⇒ rejected); letterhead upload validation rejects a non-zip.