diff --git a/crates/core/src/ext/event_bus.rs b/crates/core/src/ext/event_bus.rs
new file mode 100644
index 0000000..637d499
--- /dev/null
+++ b/crates/core/src/ext/event_bus.rs
@@ -0,0 +1,86 @@
+//
+// 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 .
+
+// Event bus extension point.
+//
+// Community edition: NoopEventBus — all events are discarded.
+// Pro edition: AuditEventBus — events are persisted to audit database.
+// Enterprise edition: adds SIEM webhook to the same trait impl.
+//
+// The open-source server emits events at key points (login, view, delete, search).
+// It never reads from the event bus — events are fire-and-forget.
+
+use std::net::IpAddr;
+use std::sync::{LazyLock, RwLock};
+
+#[derive(Debug, Clone)]
+pub enum Event {
+ EmailViewed {
+ email_id: String,
+ user: String,
+ ip: IpAddr,
+ },
+ EmailDeleted {
+ email_id: String,
+ user: String,
+ },
+ UserLoggedIn {
+ user: String,
+ ip: IpAddr,
+ },
+ UserCreated {
+ created_by: String,
+ new_user: String,
+ },
+ SearchPerformed {
+ query: String,
+ user: String,
+ },
+ SettingsChanged {
+ key: String,
+ user: String,
+ },
+ AttachmentDownloaded {
+ email_id: String,
+ content_hash: String,
+ user: String,
+ },
+}
+
+pub trait EventBus: Send + Sync {
+ fn emit(&self, event: Event);
+}
+
+/// Default — all events are discarded.
+struct NoopEventBus;
+impl EventBus for NoopEventBus {
+ fn emit(&self, _event: Event) {}
+}
+
+static EVENT_BUS: LazyLock>> =
+ LazyLock::new(|| RwLock::new(Box::new(NoopEventBus)));
+
+/// Called by Pro/Enterprise at startup to replace the noop default.
+pub fn set_event_bus(bus: Box) {
+ *EVENT_BUS.write().unwrap() = bus;
+}
+
+/// Fire-and-forget. Called by the server at key points.
+pub fn emit(event: Event) {
+ EVENT_BUS.read().unwrap().emit(event);
+}
diff --git a/crates/core/src/ext/mod.rs b/crates/core/src/ext/mod.rs
new file mode 100644
index 0000000..7f95e4b
--- /dev/null
+++ b/crates/core/src/ext/mod.rs
@@ -0,0 +1,29 @@
+//
+// 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 .
+
+// Event bus extension point.
+//
+// Community edition: NoopEventBus — all events are discarded.
+// Pro edition: AuditEventBus — events are persisted to audit database.
+// Enterprise edition: adds SIEM webhook to the same trait impl.
+//
+// The open-source server emits events at key points (login, view, delete, search).
+// It never reads from the event bus — events are fire-and-forget.
+
+pub mod event_bus;
+pub mod text_extractor;
diff --git a/crates/core/src/ext/text_extractor.rs b/crates/core/src/ext/text_extractor.rs
new file mode 100644
index 0000000..7ea817f
--- /dev/null
+++ b/crates/core/src/ext/text_extractor.rs
@@ -0,0 +1,60 @@
+//
+// 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 .
+
+// Attachment text extraction extension point.
+//
+// Community edition: NoopExtractor — no attachments are text-indexed.
+// Pro edition: PdfExtractor — extracts text from PDF, Word, etc.
+//
+// Used in: crates/core/src/envelope/extractor.rs
+
+use std::sync::{LazyLock, RwLock};
+
+pub struct ExtractedText {
+ pub text: String,
+ pub page_count: Option,
+ pub is_ocr: bool,
+}
+
+pub trait AttachmentTextExtractor: Send + Sync {
+ /// Returns None if this extractor doesn't handle the file type.
+ /// Returns Some(ExtractedText) if text was successfully extracted.
+ fn extract(&self, content_type: &str, ext: &str, bytes: &[u8]) -> Option;
+}
+
+/// Default — all attachments are skipped.
+struct NoopExtractor;
+impl AttachmentTextExtractor for NoopExtractor {
+ fn extract(&self, _ct: &str, _ext: &str, _bytes: &[u8]) -> Option {
+ None
+ }
+}
+
+static EXTRACTOR: LazyLock>> =
+ LazyLock::new(|| RwLock::new(Box::new(NoopExtractor)));
+
+/// Called by Pro/Enterprise at startup to replace the noop default.
+pub fn set_extractor(extractor: Box) {
+ *EXTRACTOR.write().unwrap() = extractor;
+}
+
+/// Called by the attachment pipeline during IMAP sync.
+/// The caller should wrap this in spawn_blocking for CPU-bound extraction.
+pub fn extract_text(content_type: &str, ext: &str, bytes: &[u8]) -> Option {
+ EXTRACTOR.read().unwrap().extract(content_type, ext, bytes)
+}
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
index cdadcd6..2440585 100644
--- a/crates/core/src/lib.rs
+++ b/crates/core/src/lib.rs
@@ -1,4 +1,5 @@
pub mod account;
+pub mod ext;
pub mod admin;
pub mod autoconfig;
pub mod cache;