diff --git a/src/modules/rest/api/attachment.rs b/src/modules/rest/api/attachment.rs index 70e52e1..2a8e31e 100644 --- a/src/modules/rest/api/attachment.rs +++ b/src/modules/rest/api/attachment.rs @@ -29,11 +29,11 @@ use crate::modules::rest::ErrorCode; use crate::modules::store::tantivy::attachment::ATTACHMENT_MANAGER; use crate::modules::store::tantivy::model::AttachmentModel; use crate::modules::users::permissions::Permission; -use crate::modules::utils::validate_tag; use crate::raise_error; use poem_openapi::param::Path; use poem_openapi::payload::Json; use poem_openapi::OpenApi; +use tantivy::schema::Facet; use std::collections::HashSet; pub struct AttachmentApi; @@ -133,8 +133,8 @@ impl AttachmentApi { ) -> ApiResult<()> { let req = req.0; for tag in &req.tags { - validate_tag(tag) - .map_err(|e| raise_error!(format!("{}", e), ErrorCode::InvalidParameter))?; + Facet::from_text(tag) + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?; } for account_id in req.updates.keys() { diff --git a/src/modules/rest/api/message.rs b/src/modules/rest/api/message.rs index 756b7e3..d0d43a5 100644 --- a/src/modules/rest/api/message.rs +++ b/src/modules/rest/api/message.rs @@ -38,12 +38,12 @@ use crate::modules::store::envelope::Envelope; use crate::modules::store::storage::get_reader; use crate::modules::store::tantivy::envelope::ENVELOPE_MANAGER; use crate::modules::users::permissions::Permission; -use crate::modules::utils::validate_tag; use crate::raise_error; use poem::Body; use poem_openapi::param::{Path, Query}; use poem_openapi::payload::{Attachment, AttachmentType, Json}; use poem_openapi::OpenApi; +use tantivy::schema::Facet; use std::collections::HashMap; use std::collections::HashSet; @@ -350,8 +350,8 @@ impl MessageApi { ) -> ApiResult<()> { let req = req.0; for tag in &req.tags { - validate_tag(tag) - .map_err(|e| raise_error!(format!("{}", e), ErrorCode::InvalidParameter))?; + Facet::from_text(tag) + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?; } for account_id in req.updates.keys() { diff --git a/src/modules/store/tantivy/model.rs b/src/modules/store/tantivy/model.rs index 5c0533f..f943ada 100644 --- a/src/modules/store/tantivy/model.rs +++ b/src/modules/store/tantivy/model.rs @@ -1,7 +1,28 @@ +// +// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + use poem_openapi::Object; use serde::{Deserialize, Serialize}; use std::collections::HashSet; -use tantivy::{schema::Value, TantivyDocument}; +use tantivy::{ + schema::{Facet, Value}, + TantivyDocument, +}; use crate::{ modules::{ @@ -107,7 +128,12 @@ impl EnvelopeWithAttachments { let tags: Vec = doc .get_all(fields.f_tags) .filter_map(|value| value.as_facet()) - .map(|f| f.to_string()) + .map(|facet_encoded_str| { + Facet::from_encoded(facet_encoded_str.as_bytes().to_vec()) + .ok() + .map(|facet| facet.to_string()) + }) + .flatten() .collect(); let account_id = extract_u64_field(doc, fields.f_account_id, F_ACCOUNT_ID)?; @@ -361,14 +387,25 @@ impl AttachmentModel { let tags: Vec = doc .get_all(f.f_tags) .filter_map(|value| value.as_facet()) - .map(|f| f.to_string()) + .map(|facet_encoded_str| { + Facet::from_encoded(facet_encoded_str.as_bytes().to_vec()) + .ok() + .map(|facet| facet.to_string()) + }) + .flatten() .collect(); let auto_tags: Vec = doc .get_all(f.f_auto_tags) .filter_map(|value| value.as_facet()) - .map(|f| f.to_string()) + .map(|facet_encoded_str| { + Facet::from_encoded(facet_encoded_str.as_bytes().to_vec()) + .ok() + .map(|facet| facet.to_string()) + }) + .flatten() .collect(); + let account_id = extract_u64_field(doc, f.f_account_id, F_ACCOUNT_ID)?; let mailbox_id = extract_u64_field(doc, f.f_mailbox_id, F_MAILBOX_ID)?; let account = AccountModel::get(account_id)?; diff --git a/src/modules/utils/mod.rs b/src/modules/utils/mod.rs index 03cbe1a..dc74d02 100644 --- a/src/modules/utils/mod.rs +++ b/src/modules/utils/mod.rs @@ -347,31 +347,6 @@ pub fn decode_avatar_bytes(base64_str: &str) -> BichonResult> { Ok(bytes) } -pub fn validate_tag(tag: &str) -> Result<(), String> { - if tag.is_empty() { - return Err("Tag cannot be empty".to_string()); - } - - const INVALID: &[char] = &[ - '\'', '"', '`', ';', ',', '(', ')', '[', ']', '{', '}', '<', '>', - ]; - - let mut found = Vec::new(); - - for c in tag.chars() { - if INVALID.contains(&c) && !found.contains(&c) { - found.push(c); - } - } - - if !found.is_empty() { - let chars: String = found.iter().collect(); - return Err(format!("Tag contains invalid characters: {}", chars)); - } - - Ok(()) -} - pub fn compute_content_hash(content: &[u8]) -> String { let hash = blake3::hash(content); hash.to_hex().to_string() diff --git a/web/src/features/search/mail-list-table.tsx b/web/src/features/search/mail-list-table.tsx index 0c85421..26bda81 100755 --- a/web/src/features/search/mail-list-table.tsx +++ b/web/src/features/search/mail-list-table.tsx @@ -173,7 +173,28 @@ export function MailListTable({ { accessorKey: "subject", header: t('search.subject'), - cell: ({ row }) => {row.original.subject}, + cell: ({ row }) => { + const tags = row.original.tags ?? []; + return ( +
+ + {row.original.subject} + + {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ ); + }, meta: { className: 'text-left text-xs' } }, { diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 0549520..d7e2dca 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -88,12 +88,28 @@ export function validateTag(facetPath: string) { }; } - const invalidChars = /['"`;,()[\]{}<>]/; - - if (invalidChars.test(facetPath)) { + if (!facetPath.startsWith('/')) { return { valid: false, - error: "Tag path contains invalid characters" + error: "Tag path must start with '/'" + }; + } + + let escaped = false; + for (let i = 1; i < facetPath.length; i++) { + const char = facetPath[i]; + + if (escaped) { + escaped = false; + } else if (char === '\\') { + escaped = true; + } + } + + if (escaped) { + return { + valid: false, + error: "Tag path has unmatched escape character at the end" }; } @@ -101,6 +117,7 @@ export function validateTag(facetPath: string) { } + export function formatTimestamp(milliseconds: number): string { const date = new Date(milliseconds); const year = date.getFullYear();