Recipe: Attachment Manager app for App Builder | The place for Zendesk users to come together and share
Skip to main content

Recipe: Attachment Manager app for App Builder

  • April 29, 2026
  • 0 replies
  • 10 views

Vishal13

Problem Statement

The Attachment Manager app is a ticket sidebar application that helps manage ticket attachments. It lets agents redact sensitive files, control which file types are allowed/invalid, organize attachments in a library, and tag tickets based on attachments.

 

Prompt

Create a ticket_sidebar app called "Attachment Manager" that lets agents view, redact, and manage ticket attachments through an accordion-style interface with three sections: All Attachments, Invalid Attachments, and a personal Attachment Library.

 

Define five app parameters in the manifest so they appear on the app's installation settings screen:

  • `attachment_tag` — labelled "Attachment tag". Help text: "Tag added to existing tickets which have attachments." (type: text, required, default: `has_attachment`)
  • `new_attachment_tag` — labelled "New attachment tag". Help text: "Tag to be added to new ticket comments with attachments." (type: text, required, default: `has_new_attachment`)
  • `allowlist` — labelled "Valid attachment list". Help text: "A comma-separated list of file extensions used to identify attachments as always valid on a ticket. This overrides any matching blocklist extensions. Examples: png, jpg" (type: multiline, optional)
  • `blocklist` — labelled "Invalid attachment list". Help text: "A comma-separated list of file extensions used to identify invalid attachments on a ticket. Examples: exe, zip" (type: multiline, optional)
  • `items_per_page` — labelled "Library items per page". Help text: "Number of attachments to display per page" (type: number, required, default: `6`)

 

Read these at runtime by calling `zafClient.metadata()` which returns an object with a `settings` property. Access individual settings from `metadata.settings`.

---

**Shared helper: fetchAttachments**

Create a reusable async function called `fetchAttachments(zafClient)` that both the "All attachments" and "Invalid attachments" sections call. This function does:

1. Get the ticket ID via `zafClient.get('ticket.id')`.

2. Call `GET /api/v2/tickets/{ticketId}/comments.json?include_inline_images=true`.

3. The JSON response has a `comments` array. Loop over every comment. For each comment, loop over its `attachments` array. Skip any attachment where ALL THREE of these are true: `file_name === "redacted.txt"` AND `size === 1` AND `content_type === "text/plain"`.

4. For every non-skipped attachment, build an object: `{ id: attachment.id, file_name: attachment.file_name, content_url: attachment.content_url, size: attachment.size, content_type: attachment.content_type, comment_id: comment.id, created_at: comment.created_at }`.

5. Collect all these objects into an array, then reverse the array so the most-recent comment's attachments appear first.

6. Return the array.

 

**Shared helper: formatFileSize**

Create a function `formatFileSize(bytes)` that converts bytes to a human-readable string: if bytes ≥ 1073741824 return `(bytes / 1073741824).toFixed(2) + ' GB'`, else if bytes ≥ 1048576 return `(bytes / 1048576).toFixed(2) + ' MB'`, else if bytes ≥ 1024 return `(bytes / 1024).toFixed(2) + ' KB'`, else return `bytes + ' Bytes'`.

 

**Shared helper: formatDate**

Create a function `formatDate(isoString)` that formats an ISO date string as `M/DD/YY` — for example `2026-04-23T...` becomes `4/23/26`. No leading zero on month, two-digit day (with leading zero), two-digit year.

 

**Shared component: AttachmentTable**

Create a reusable React component `AttachmentTable` that accepts props `attachments` (array), `selectedIds` (Set), and `onSelectionChange` (callback that receives the updated Set). It renders a `<table>` with `width: 100%; border-collapse: collapse`. The table has four columns:

 

  • Column 1 (width: 40px): The header cell contains a single `<input type="checkbox" />` with no text next to it — this is the select-all toggle. Each body row cell contains a single `<input type="checkbox" />` with no text next to it. Do NOT use the Garden Checkbox component. Use plain HTML `<input type="checkbox" />` only. Style each checkbox: `width: 16px; height: 16px; cursor: pointer; margin: 0`. The select-all checkbox is checked when all rows are selected, unchecked when none are.
  • Column 2 "Name" (header text: "Name", left-aligned): Render the attachment's `file_name` as an `<a href={content_url} target="_blank">` link. Style the link with `overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; max-width: 200px`.
  • Column 3 "Created" (header text: "Created", right-aligned): Show `formatDate(attachment.created_at)`.
  • Column 4 "Size" (header text: "Size", right-aligned): Show `formatFileSize(attachment.size)`.

 

Style table header cells with `font-weight: 600; padding: 8px 4px; border-bottom: 1px solid #d8dcde`. Style body cells with `padding: 8px 4px; border-bottom: 1px solid #e9ebed`.

 

When the `attachments` array is empty, instead of the table render centred grey text: "No attachments found".

 

---

**Initial data load (on app mount)**

1. On app mount (in a `useEffect` with empty dependency array), immediately call `fetchAttachments(zafClient)` and store the result in React state as `allAttachments`. Also call `const metadata = await zafClient.metadata()` and read the `allowlist` and `blocklist` settings. Parse each setting: if falsy or empty, treat as empty array; otherwise `.replace(/\s/g, '').replace(/^,+|,+$/g, '')`, split on `,`, filter empty strings, convert to lowercase. Filter `allAttachments` to compute the invalid attachments array (if allowlist has entries, invalid = extension NOT in allowlist; if only blocklist, invalid = extension IS in blocklist; if both empty, invalid count = 0). Store the invalid attachments in state as `invalidAttachments`. Set `allAttachmentsCount` and `invalidAttachmentsCount` in state so the badge counts are available immediately. Also register `zafClient.on('ticket.updated', callback)` to re-run this entire fetch-and-compute on ticket changes.

 

**Accordion layout**

2. Build a vertical accordion with three buttons stacked top-to-bottom, labelled "All attachments", "Invalid attachments", and "Library". Use React state `openSection` (value: `null`, `'all'`, `'invalid'`, or `'library'`). Style each button with: `width: 100%; text-align: left; padding: 10px 15px; margin-bottom: 5px; border: 1px solid #d8dcde; border-radius: 4px; cursor: pointer; font-size: 14px`. When a section is open, its button gets: `background: #5293C7; color: white; border-color: #5293C7`. When closed: `background: white; color: #2f3941`. Clicking a button toggles its section (open → close, closed → open) and closes any other open section. Render the open section's content below its button. After every state change, call `setTimeout(() => zafClient.invoke('resize', { width: '100%', height: document.body.scrollHeight + 10 + 'px' }), 100)`.

 

---

 

**Section 1 — All Attachments**

3. The "All attachments" button displays `allAttachmentsCount` as a badge — a `<span>` floated right with `padding: 2px 7px; border-radius: 3px; font-size: 12px; line-height: 1`. When this section is expanded: `background: white; color: #5293C7`. When collapsed: `background: #5293C7; color: white`. Only show the badge when the count is greater than 0. This badge is always visible (even before the section is opened) because the count was computed in step 1.

 

4. When the "All attachments" section is open, render the `AttachmentTable` component using the `allAttachments` state from step 1. Maintain a separate `selectedIds` Set in state for this section's checkboxes. Below the table, render a right-aligned "Redact attachments" button (Garden `Button`, `isPrimary`, `isDanger`). Disable this button when `selectedIds` is empty.

 

**Redaction flow**

5. When "Redact attachments" is clicked, show a confirmation dialog within the sidebar. Use a `<div>` overlay with `position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.4); z-index: 1000; display: flex; align-items: center; justify-content: center`. Inside, a white `<div>` with `padding: 24px; border-radius: 8px; max-width: 90%; width: 400px`. Title "Attachment Manager" at the top in bold. Then two paragraphs: "This action will permanently remove the selected attachments from this ticket. Once removed from a comment, the attachment is replaced with an empty \"redacted.txt\" file." and "It is not possible to undo redaction or see what was removed."

 

6. Below the text, show "Please type in REMOVE to confirm." with a Garden `Input` (placeholder text "REMOVE", `validation="error"`). Below it, a full-width "Redact selected attachments" button (Garden `Button`, `isPrimary`, `isDanger`, `isStretched`). Keep this button disabled until the input value exactly equals the string `REMOVE`.

 

7. When "Redact selected attachments" is clicked, process each selected attachment one at a time sequentially (not in parallel). For each, call `PUT /api/v2/tickets/{ticketId}/comments/{attachment.comment_id}/attachments/{attachment.id}/redact.json` with body `{}`. On success, call `zafClient.invoke('notify', attachment.file_name + ' successfully redacted')`. On failure, call `zafClient.invoke('notify', 'Failed to redact ' + attachment.file_name, 'error')`. Continue even if some fail.

 

8. After all redactions finish, replace the input and button with a single full-width "Close" button. Clicking "Close" hides the dialog. After closing, re-run the initial data load from step 1 (re-fetch attachments, recompute both counts and invalid list) to refresh everything.

 

---

 

**Section 2 — Invalid Attachments**

9. The "Invalid attachments" button displays `invalidAttachmentsCount` as a badge with the same styling as the "All attachments" badge (step 3). This badge is always visible because the count was computed in step 1.

 

10. If `parsedAllowlist` and `parsedBlocklist` (from step 1) are both empty, render a red error message: a `<div>` with `color: #cc3340; background: #fff0f1; border: 1px solid #cc3340; border-radius: 4px; padding: 12px` containing "No allowlist or blocklist found. Please configure the app settings." Do NOT render a table.

 

11. If at least one list has entries, and `invalidAttachments` (from step 1) has items, show a red error message: "Invalid attachments found: {invalidAttachmentsCount}". Below it, render the `AttachmentTable` component with the `invalidAttachments` array and its own independent `selectedIds` state. Below the table, render a "Redact attachments" button with the same redaction flow as steps 5–8. After redaction completes, re-run step 1 to refresh.

 

12. If zero invalid attachments were found and at least one list is configured, show centred grey text: "All clear. No invalid attachments found."

 

---

 

**Section 3 — Library**

13. The "Library" button has no count badge.

14. Inside the section, render three tabs. Use Garden `Tabs` component with three `TabPanel`s labelled "Library", "Ticket", and "External". Default to the "Library" tab.

 

**Library tab (first tab)**

15. On mount, get the current user's ID via `zafClient.get('currentUser.id')`. Then call `GET /api/v2/users/{userId}.json`. From the response, read `user.user_fields.attachment_library`. This field stores a JSON string like `{"attachments": [...]}`. Parse it with `JSON.parse()`. If the field is `null`, undefined, an empty string, or `JSON.parse` throws, default to an empty array `[]`. Otherwise extract the `attachments` array from the parsed object. Store in React state as `libraryAttachments`.

 

16. Display `libraryAttachments` as a grid of clickable cards. Use `display: grid; grid-template-columns: repeat(auto-fill, 80px); gap: 8px`. Each card is 80px × 80px with `border: 1px solid #ddd; border-radius: 4px; cursor: pointer; overflow: hidden; display: flex; align-items: center; justify-content: center`. For attachments with `type === "image"`, render `<img src={content_url} style="width: 100%; height: 100%; object-fit: cover" />`. For non-image attachments, render a grey document icon (use a simple SVG: a rectangle with horizontal lines inside). When selected, the card border changes to `2px solid #78A300`. Below each card, show the file name as a small link: `<a href={content_url} target="_blank" style="display: block; max-width: 80px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px">{file_name}</a>`. When the array is empty, show: "No attachments added to library yet".

 

17. Paginate: call `const metadata = await zafClient.metadata()` and read `metadata.settings.items_per_page` to get the page size. Only show that many items at a time. Use Garden `Pagination` below the grid. Track `currentPage` in state.

 

18. Below the pagination, render three buttons side by side: "Embed image" (Garden `Button`), "Embed link" (Garden `Button`), and "Remove" (Garden `Button`, `isDanger`). If no cards are selected, all three show an error notification via `zafClient.invoke('notify', 'Please select an attachment first', 'error')`. **Embed image**: for each selected attachment where `type === "image"`, call `zafClient.invoke('comment.appendHtml', '<img src="' + content_url + '" alt="' + file_name + '">')`. If the attachment is not an image, notify: `zafClient.invoke('notify', file_name + ' is not an image', 'error')`. **Embed link**: for each selected attachment, call `zafClient.invoke('comment.appendHtml', '<a href="' + content_url + '">' + file_name + '</a>')`. **Remove**: remove the selected attachments from `libraryAttachments`, then save (step 19).

 

19. To save the library, build the object `{ attachments: libraryAttachments }`, serialize it to a JSON string with `JSON.stringify(...)`, then call `PUT /api/v2/users/{userId}.json` with body: `{ "user": { "user_fields": { "attachment_library": theJsonString } } }`. Note: the value of `attachment_library` is a string, not an object — pass the result of `JSON.stringify(...)` as the value.

 

**Ticket tab (second tab)**

20. On mount (when this tab becomes active), get the ticket ID via `zafClient.get('ticket.id')`, then call `GET /api/v2/tickets/{ticketId}/comments.json?include_inline_images=true`. Loop over the `comments` array, then each comment's `attachments` array. Skip attachments where `file_name === "redacted.txt"` AND `size === 1` AND `content_type === "text/plain"`. For each remaining attachment, build: `{ id: attachment.id, file_name: attachment.file_name, content_url: attachment.content_url, size: attachment.size, thumbnail_url: (attachment.thumbnails && attachment.thumbnails.length > 0) ? attachment.thumbnails[0].content_url : null, type: (attachment.width > 0 && attachment.height > 0) ? "image" : "file" }`. Collect into a `ticketAttachments` array.

 

21. Display `ticketAttachments` as a thumbnail grid (same card layout as step 16 — use `thumbnail_url` for the image if available, falling back to `content_url` for image types). Below the grid, show three buttons: "Embed image", "Embed link" (same behavior as step 18), and "Add to library". **Add to library**: for each selected attachment, check if `libraryAttachments` already contains an item with the same `id` — if yes, notify `zafClient.invoke('notify', file_name + ' already added to library', 'alert')` and skip. Otherwise, push the attachment onto `libraryAttachments` and save (step 19). Register `zafClient.on('ticket.updated', callback)` to re-fetch when the ticket changes.

 

**External tab (third tab)**

22. Show a form with three fields stacked vertically:

- "External file URL": a Garden `Input`. Hint text: "Must start with https://". Validate that the value starts with `https://`.

- "Name for file": a Garden `Input`. Hint text: "Some special characters will be blocked". On change, strip these characters: `* : " . / \ < > ? |`.

- "File type": a Garden `Toggle` labelled "Image".

 

Below the form, a right-aligned "Add to library" button. On click: validate URL starts with `https://` and name is non-empty. If the Image toggle is on, verify the URL loads by creating `new Image()`, setting `src`, and checking `onload` fires. Add to `libraryAttachments`: `{ id: Date.now(), content_url: url, file_name: sanitizedName, isExternal: true, type: toggleIsOn ? "image" : "file" }`. Save via step 19.

 

---

**Automatic attachment tagging**

23. On app load, call `zafClient.get(['ticket.id', 'ticket.tags', 'ticket.comments'])`. Read the `attachment_tag` setting from `const metadata = await zafClient.metadata()` as `metadata.settings.attachment_tag`. From `ticket.comments`, check if any comment has `imageAttachments.length > 0` or `nonImageAttachments.length > 0`. If yes and `attachment_tag` is not in `ticket.tags`, call `zafClient.get('ticket.updatedAt')` then `PUT /api/v2/tickets/{ticketId}/tags.json` with body `{ "safe_update": true, "updated_stamp": updatedAt, "tags": [attachment_tag] }`.

 

24. Register `zafClient.on('ticket.comments.changed', callback)` to re-run the tag check from step 23 when comments change.

 

25. Register `zafClient.on('ticket.save', async callback)`. Inside: read `attachment_tag` and `new_attachment_tag` from `metadata.settings`. Check if comments have attachments and `attachment_tag` is not in tags — if so, call `zafClient.invoke('ticket.tags.add', attachment_tag)`. Then call `zafClient.get('comment.attachments')` — if the current draft has attachments and `new_attachment_tag` is not in tags, call `zafClient.invoke('ticket.tags.add', new_attachment_tag)`. Return `true` from the callback to allow the save.

 

 

Enhancement Options

Based on your organizational needs, you can extend the Attachment Manager app to support your specific needs using App Builder.

 

Important Note: This app recipe is for the Attachment Manager app. Any modifications to the recipe or its underlying logic may alter the core functionality of the app. Thoroughly test all changes in a non-production environment before deploying to your production workspace.

Please note: that as App Builder evolves including updates to underlying AI models, design components, and other dependencies, we cannot guarantee that this recipe will continue to function as expected over time. We recommend periodically validating the recipe against any platform updates.