From 3c67c415442ea115dc0b1bc142875ff43eb65695 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Fri, 22 May 2026 11:09:27 +0530
Subject: [PATCH] chore: support PFX filetype in attachment uploads (#14456)
# Pull Request Template
## Description
This PR expands the default upload rules to support PFX certificate
files (`application/x-pkcs12`, `application/pkcs12`, `.pfx`) across
private notes, Website, Email, and Telegram channels.
Also adds `.xls` / `.xlsx` extension fallbacks for cases where browsers
upload Excel files with an empty or generic MIME type.
### Utils Repo PR: https://github.com/chatwoot/utils/pull/61
Fixes
https://linear.app/chatwoot/issue/CW-7085/support-more-file-types-in-private-notes-and-in-app
## Type of change
- [x] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
### Screenshots
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---------
Co-authored-by: aakashb95
---
.../components-next/icon/FileIcon.vue | 1 +
.../widgets/WootWriter/ReplyBottomPanel.vue | 10 +---------
app/javascript/shared/helpers/FileHelper.js | 4 +---
app/models/attachment.rb | 18 ++++++++++++++++--
package.json | 2 +-
pnpm-lock.yaml | 10 +++++-----
theme/icons.js | 17 +++++++++++++++++
7 files changed, 42 insertions(+), 20 deletions(-)
diff --git a/app/javascript/dashboard/components-next/icon/FileIcon.vue b/app/javascript/dashboard/components-next/icon/FileIcon.vue
index 8dd9e7ce1..d82be3e69 100644
--- a/app/javascript/dashboard/components-next/icon/FileIcon.vue
+++ b/app/javascript/dashboard/components-next/icon/FileIcon.vue
@@ -18,6 +18,7 @@ const fileTypeIcon = computed(() => {
json: 'i-woot-file-txt',
odt: 'i-woot-file-doc',
pdf: 'i-woot-file-pdf',
+ pfx: 'i-woot-file-pfx',
ppt: 'i-woot-file-ppt',
pptx: 'i-woot-file-ppt',
rar: 'i-woot-file-zip',
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
index ff569d763..2e5a9aab2 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue
@@ -7,7 +7,6 @@ import * as ActiveStorage from 'activestorage';
import inboxMixin from 'shared/mixins/inboxMixin';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
-import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { mapGetters } from 'vuex';
@@ -166,11 +165,6 @@ export default {
uploadRef,
};
},
- data() {
- return {
- ALLOWED_FILE_TYPES,
- };
- },
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
@@ -212,13 +206,11 @@ export default {
return this.conversationType === 'instagram_direct_message';
},
allowedFileTypes() {
- // Use default file types for private notes
if (this.isOnPrivateNote) {
- return this.ALLOWED_FILE_TYPES;
+ return getAllowedFileTypesByChannel();
}
let channelType = this.channelType || this.inbox?.channel_type;
-
if (this.isAnInstagramChannel || this.isInstagramDM) {
channelType = INBOX_TYPES.INSTAGRAM;
}
diff --git a/app/javascript/shared/helpers/FileHelper.js b/app/javascript/shared/helpers/FileHelper.js
index 2616c868a..93c8a7156 100644
--- a/app/javascript/shared/helpers/FileHelper.js
+++ b/app/javascript/shared/helpers/FileHelper.js
@@ -1,6 +1,5 @@
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
-import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
export const DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE = 40;
@@ -58,9 +57,8 @@ export const isFileTypeAllowedForChannel = (file, options = {}) => {
isOnPrivateNote,
} = options;
- // Use broader file types for private notes (matches file picker behavior)
const allowedFileTypes = isOnPrivateNote
- ? ALLOWED_FILE_TYPES
+ ? getAllowedFileTypesByChannel()
: getAllowedFileTypesByChannel({
channelType:
isInstagramChannel || conversationType === 'instagram_direct_message'
diff --git a/app/models/attachment.rb b/app/models/attachment.rb
index 2d46f3b7e..102d90beb 100644
--- a/app/models/attachment.rb
+++ b/app/models/attachment.rb
@@ -33,7 +33,10 @@ class Attachment < ApplicationRecord
application/vnd.openxmlformats-officedocument.presentationml.presentation
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
application/vnd.openxmlformats-officedocument.wordprocessingml.document
+ application/x-pkcs12 application/pkcs12
].freeze
+ ACCEPTABLE_FILE_EXTENSIONS = %w[pfx].freeze
+ GENERIC_FILE_CONTENT_TYPES = %w[application/octet-stream].freeze
belongs_to :account
belongs_to :message
has_one_attached :file
@@ -195,7 +198,10 @@ class Attachment < ApplicationRecord
end
def validate_file_content_type(file_content_type)
- errors.add(:file, 'type not supported') unless media_file?(file_content_type) || ACCEPTABLE_FILE_TYPES.include?(file_content_type)
+ return if media_file?(file_content_type) || ACCEPTABLE_FILE_TYPES.include?(file_content_type)
+ return if generic_file_content_type?(file_content_type) && ACCEPTABLE_FILE_EXTENSIONS.include?(file_extension)
+
+ errors.add(:file, 'type not supported')
end
def validate_file_size(byte_size)
@@ -206,7 +212,15 @@ class Attachment < ApplicationRecord
end
def media_file?(file_content_type)
- file_content_type.start_with?('image/', 'video/', 'audio/')
+ file_content_type.to_s.start_with?('image/', 'video/', 'audio/')
+ end
+
+ def generic_file_content_type?(file_content_type)
+ file_content_type.blank? || GENERIC_FILE_CONTENT_TYPES.include?(file_content_type)
+ end
+
+ def file_extension
+ File.extname(file.filename.to_s).delete_prefix('.').downcase
end
end
diff --git a/package.json b/package.json
index ed8bbf00f..1e506d6c5 100644
--- a/package.json
+++ b/package.json
@@ -35,7 +35,7 @@
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.3.13",
- "@chatwoot/utils": "^0.0.52",
+ "@chatwoot/utils": "^0.0.55",
"@formkit/core": "^1.7.2",
"@formkit/vue": "^1.7.2",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3f1e9e2cf..3a9efa7eb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -29,8 +29,8 @@ importers:
specifier: 1.3.13
version: 1.3.13
'@chatwoot/utils':
- specifier: ^0.0.52
- version: 0.0.52
+ specifier: ^0.0.55
+ version: 0.0.55
'@formkit/core':
specifier: ^1.7.2
version: 1.7.2
@@ -462,8 +462,8 @@ packages:
'@chatwoot/prosemirror-schema@1.3.13':
resolution: {integrity: sha512-T6FBUinMJbwDCD7975g8M/Tsn2+G3O2pTGIXdcLkMRpbAAC6mVdl4ZcZektlt5y/PVmPVqNHPsfee1XB/C3vAw==}
- '@chatwoot/utils@0.0.52':
- resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==}
+ '@chatwoot/utils@0.0.55':
+ resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==}
engines: {node: '>=10'}
'@codemirror/commands@6.7.0':
@@ -5011,7 +5011,7 @@ snapshots:
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
prosemirror-view: 1.34.1
- '@chatwoot/utils@0.0.52':
+ '@chatwoot/utils@0.0.55':
dependencies:
date-fns: 2.30.0
diff --git a/theme/icons.js b/theme/icons.js
index 266c7ddfa..281e21c23 100644
--- a/theme/icons.js
+++ b/theme/icons.js
@@ -113,6 +113,23 @@ export const icons = {
width: 16,
height: 20,
},
+ 'file-pfx': {
+ body: `
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ width: 16,
+ height: 20,
+ },
bin: {
body: ``,
width: 16,