mirror of
https://github.com/LogicLabs-OU/OpenArchiver.git
synced 2026-04-06 00:31:57 +02:00
Compare commits
156 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c42b30c9e | ||
|
|
e5e119528f | ||
|
|
20ef9a42ae | ||
|
|
81b87b4b7e | ||
|
|
b5f95760f4 | ||
|
|
85000ad82b | ||
|
|
c5672d0f81 | ||
|
|
9b303c963e | ||
|
|
9228f64221 | ||
|
|
481a5ce6f9 | ||
|
|
3434e8d6ef | ||
|
|
7dac3b2bfd | ||
|
|
cf121989ae | ||
|
|
2df5c9240d | ||
|
|
24afd13858 | ||
|
|
c2006dfa94 | ||
|
|
399059a773 | ||
|
|
0cff788656 | ||
|
|
ddb4d56107 | ||
|
|
42b0f6e5f1 | ||
|
|
6e1ebbbfd7 | ||
|
|
1e048fdbc1 | ||
|
|
b71dd55e25 | ||
|
|
d372ef7566 | ||
|
|
e9a65f9672 | ||
|
|
ce3f379b7a | ||
|
|
37a778cb6d | ||
|
|
26a760b232 | ||
|
|
6be0774bc4 | ||
|
|
4a23f8f29f | ||
|
|
074256ed59 | ||
|
|
7d178d786b | ||
|
|
4b11cd931a | ||
|
|
0a21ad14cd | ||
|
|
63d3960f79 | ||
|
|
85a526d1b6 | ||
|
|
52a1a11973 | ||
|
|
4048f47777 | ||
|
|
22b173cbe4 | ||
|
|
774b0d7a6b | ||
|
|
85607d2ab3 | ||
|
|
94021eab69 | ||
|
|
faefdac44a | ||
|
|
392f51dabc | ||
|
|
baff1195c7 | ||
|
|
f1da17e484 | ||
|
|
a2c55f36ee | ||
|
|
9fdba4cd61 | ||
|
|
108c646596 | ||
|
|
61e44c81f7 | ||
|
|
f651aeab0e | ||
|
|
3fb4290934 | ||
|
|
8c33b63bdf | ||
|
|
2b325f3461 | ||
|
|
4d3c164bc0 | ||
|
|
7288286fd9 | ||
|
|
ec1cf3cf0b | ||
|
|
9c9152a2ee | ||
|
|
c05b3b92d9 | ||
|
|
aed0c964c8 | ||
|
|
86dda6c6d3 | ||
|
|
6e1dd17267 | ||
|
|
b4d2125020 | ||
|
|
a2ca79d3eb | ||
|
|
8f519dc995 | ||
|
|
b2ca3ef0e1 | ||
|
|
9873228d01 | ||
|
|
94190f8b7c | ||
|
|
832e29bd92 | ||
|
|
cba6dfcae1 | ||
|
|
24f5b341a8 | ||
|
|
cba7e05d98 | ||
|
|
cfdfe42fb8 | ||
|
|
9138c1c753 | ||
|
|
c4afa471cb | ||
|
|
187282c68d | ||
|
|
82a83a71e4 | ||
|
|
ff676ecb86 | ||
|
|
9ff6801afc | ||
|
|
d2b4337be9 | ||
|
|
b03791d9a6 | ||
|
|
4cbbb6cec3 | ||
|
|
f10bf93d1b | ||
|
|
512f0312ba | ||
|
|
29db34c5d8 | ||
|
|
a87000f9dc | ||
|
|
4872ed597f | ||
|
|
e02ad0355e | ||
|
|
23ebe942b2 | ||
|
|
842f8092d6 | ||
|
|
3201fbfe0b | ||
|
|
d5c9f9a14b | ||
|
|
f2a5b29105 | ||
|
|
c65d80e948 | ||
|
|
f484f72994 | ||
|
|
59ca07dd1a | ||
|
|
d74d5e5308 | ||
|
|
1ae7b2fd2f | ||
|
|
e0953e270e | ||
|
|
705b1e5311 | ||
|
|
5a2ca3bf19 | ||
|
|
f0678b3aa9 | ||
|
|
4156abcdfa | ||
|
|
d47f0c5b08 | ||
|
|
a18e34a486 | ||
|
|
be3127136f | ||
|
|
26aeaa7c2d | ||
|
|
07cc1e5075 | ||
|
|
d9971e3ff4 | ||
|
|
3ff50ec155 | ||
|
|
181f4fd46c | ||
|
|
ffaa9762af | ||
|
|
f45ed3a62a | ||
|
|
95445dcd37 | ||
|
|
0d64eff208 | ||
|
|
6fb459630e | ||
|
|
227e8d8d18 | ||
|
|
a5d3a3be86 | ||
|
|
8695f484ac | ||
|
|
ed15c0e9bd | ||
|
|
e09c82f1fe | ||
|
|
6a154a8f02 | ||
|
|
ac4dae08d2 | ||
|
|
c297e5a714 | ||
|
|
5cc24d0d67 | ||
|
|
488df16f26 | ||
|
|
e9d84fb438 | ||
|
|
32752ce90f | ||
|
|
42dc884588 | ||
|
|
563e2dcae4 | ||
|
|
b2f41062f8 | ||
|
|
4e0f6ce5df | ||
|
|
e68d9a338d | ||
|
|
a7e6b93c77 | ||
|
|
9d3e6fc22e | ||
|
|
16e6d04682 | ||
|
|
cb04da78a6 | ||
|
|
36dbd426d5 | ||
|
|
8985655a48 | ||
|
|
9b0c136fff | ||
|
|
88046c38e4 | ||
|
|
9c5922fd31 | ||
|
|
7240da7b40 | ||
|
|
898f52ac78 | ||
|
|
becd5f1490 | ||
|
|
1d907abdbd | ||
|
|
8a74838f43 | ||
|
|
6930162079 | ||
|
|
748240b16e | ||
|
|
88cb5340a7 | ||
|
|
e95093c439 | ||
|
|
a96b32e0e9 | ||
|
|
b081c802b7 | ||
|
|
7d60a8fe6e | ||
|
|
5217d24184 | ||
|
|
8c12cda370 |
112
.env.example
112
.env.example
@@ -1,27 +1,63 @@
|
||||
# Application
|
||||
|
||||
# --- Application Settings ---
|
||||
# Set to 'production' for production environments
|
||||
NODE_ENV=development
|
||||
PORT_BACKEND=4000
|
||||
PORT_FRONTEND=3000
|
||||
# The public-facing URL of your application. This is used by the backend to configure CORS.
|
||||
APP_URL=http://localhost:3000
|
||||
# This is used by the SvelteKit Node adapter to determine the server's public-facing URL.
|
||||
# It should always be set to the value of APP_URL.
|
||||
ORIGIN=$APP_URL
|
||||
# The frequency of continuous email syncing. Default is every minutes, but you can change it to another value based on your needs.
|
||||
SYNC_FREQUENCY='* * * * *'
|
||||
# Set to 'true' to include Junk and Trash folders in the email archive. Defaults to false.
|
||||
ALL_INCLUSIVE_ARCHIVE=false
|
||||
# Number of mailbox jobs that run concurrently in the ingestion worker. Increase on servers with more RAM.
|
||||
INGESTION_WORKER_CONCURRENCY=5
|
||||
|
||||
# --- Docker Compose Service Configuration ---
|
||||
# These variables are used by docker-compose.yml to configure the services. Leave them unchanged if you use Docker services for Postgresql, Valkey (Redis) and Meilisearch. If you decide to use your own instances of these services, you can substitute them with your own connection credentials.
|
||||
|
||||
# PostgreSQL
|
||||
DATABASE_URL="postgresql://admin:password@postgres:5432/open_archive?schema=public"
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
POSTGRES_DB=open_archive
|
||||
POSTGRES_USER=admin
|
||||
POSTGRES_PASSWORD=password
|
||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}"
|
||||
|
||||
# Meilisearch
|
||||
MEILI_MASTER_KEY=aSampleMasterKey
|
||||
MEILI_HOST=http://meilisearch:7700
|
||||
# The number of emails to batch together for indexing. Defaults to 500.
|
||||
MEILI_INDEXING_BATCH=500
|
||||
|
||||
# Storage
|
||||
|
||||
# Redis (We use Valkey, which is Redis-compatible and open source)
|
||||
REDIS_HOST=valkey
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=defaultredispassword
|
||||
# If you run Valkey service from Docker Compose, set the REDIS_TLS_ENABLED variable to false.
|
||||
REDIS_TLS_ENABLED=false
|
||||
# Redis username. Only required if not using the default user.
|
||||
REDIS_USER=notdefaultuser
|
||||
|
||||
|
||||
# --- Storage Settings ---
|
||||
# Choose your storage backend. Valid options are 'local' or 's3'.
|
||||
STORAGE_TYPE=local
|
||||
# The maximum request body size the SvelteKit frontend server will accept (including file uploads via streaming).
|
||||
# Accepts a numeric value in bytes, or a unit suffix: K (kilobytes), M (megabytes), G (gigabytes).
|
||||
# Set to 'Infinity' to remove the limit entirely (recommended for archiving large PST/Mbox files).
|
||||
# Examples: 512K, 100M, 5G, Infinity. Defaults to 512K if not set.
|
||||
# For very large files (multi-GB), consider using the "Local Path" ingestion option which bypasses this limit entirely.
|
||||
BODY_SIZE_LIMIT=100M
|
||||
|
||||
# --- Local Storage Settings ---
|
||||
# The absolute path on the server where files will be stored.
|
||||
# This is only used if STORAGE_TYPE is 'local'.
|
||||
# The path inside the container where files will be stored.
|
||||
# This is mapped to a Docker volume for persistence.
|
||||
# This is not an optional variable, it is where the Open Archiver service stores application data. Set this even if you are using S3 storage.
|
||||
# Make sure the user that runs the Open Archiver service has read and write access to this path.
|
||||
# Important: It is recommended to create this path manually before installation, otherwise you may face permission and ownership problems.
|
||||
STORAGE_LOCAL_ROOT_PATH=/var/data/open-archiver
|
||||
|
||||
# --- S3-Compatible Storage Settings ---
|
||||
@@ -34,18 +70,58 @@ STORAGE_S3_REGION=
|
||||
# Set to 'true' for MinIO and other non-AWS S3 services
|
||||
STORAGE_S3_FORCE_PATH_STYLE=false
|
||||
|
||||
# --- Storage Encryption ---
|
||||
# IMPORTANT: Generate a secure, random 32-byte hex string for this key.
|
||||
# You can use `openssl rand -hex 32` to generate a key.
|
||||
# This key is used for AES-256 encryption of files at rest.
|
||||
# This is an optional variable, if not set, files will not be encrypted.
|
||||
STORAGE_ENCRYPTION_KEY=
|
||||
|
||||
# --- Security & Authentication ---
|
||||
|
||||
# Enable or disable deletion of emails and ingestion sources. Defaults to false.
|
||||
ENABLE_DELETION=false
|
||||
|
||||
# Rate Limiting
|
||||
# The window in milliseconds for which API requests are checked. Defaults to 60000 (1 minute).
|
||||
RATE_LIMIT_WINDOW_MS=60000
|
||||
# The maximum number of API requests allowed from an IP within the window. Defaults to 100.
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
|
||||
|
||||
|
||||
# JWT
|
||||
JWT_SECRET="a-very-secret-key"
|
||||
# IMPORTANT: Change this to a long, random, and secret string in your .env file
|
||||
JWT_SECRET=a-very-secret-key-that-you-should-change
|
||||
JWT_EXPIRES_IN="7d"
|
||||
|
||||
|
||||
|
||||
# Admin users
|
||||
ADMIN_EMAIL=admin@local.com
|
||||
ADMIN_PASSWORD=a_strong_pass
|
||||
SUPER_API_KEY=
|
||||
|
||||
# Master Encryption Key for sensitive data
|
||||
# Master Encryption Key for sensitive data (Such as Ingestion source credentials and passwords)
|
||||
# IMPORTANT: Generate a secure, random 32-byte hex string for this
|
||||
# Use `openssl rand -hex 32` to generate a key
|
||||
# You can use `openssl rand -hex 32` to generate a key.
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# Apache Tika Integration
|
||||
# ONLY active if TIKA_URL is set
|
||||
TIKA_URL=http://tika:9998
|
||||
|
||||
|
||||
# Enterprise features (Skip this part if you are using the open-source version)
|
||||
|
||||
# Batch size for managing retention policy lifecycle. (This number of emails will be checked each time when retention policy scans the database. Adjust based on your system capability.)
|
||||
RETENTION_BATCH_SIZE=1000
|
||||
|
||||
# --- SMTP Journaling (Enterprise only) ---
|
||||
# The port the embedded SMTP journaling listener binds to inside the container.
|
||||
# This is the port your MTA (Exchange, MS365, Postfix, etc.) will send journal reports to.
|
||||
# The docker-compose.yml maps this same port on the host side by default.
|
||||
SMTP_JOURNALING_PORT=2525
|
||||
# The domain used to generate routing addresses for journaling sources.
|
||||
# Each source gets a unique address like journal-<id>@<domain>.
|
||||
# Set this to the domain/subdomain whose MX record points to this server.
|
||||
SMTP_JOURNALING_DOMAIN=journal.yourdomain.com
|
||||
# Maximum number of waiting jobs in the journal queue before the SMTP listener
|
||||
# returns 4xx temporary failures (backpressure). The MTA will retry automatically.
|
||||
JOURNAL_QUEUE_BACKPRESSURE_THRESHOLD=10000
|
||||
#BullMQ worker concurrency for processing journaled emails. Increase on servers with more CPU cores.
|
||||
JOURNAL_WORKER_CONCURRENCY=3
|
||||
27
.github/CLA-v2.md
vendored
Normal file
27
.github/CLA-v2.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# Contributor License Agreement (CLA)
|
||||
|
||||
Version: 2
|
||||
|
||||
This Agreement is for your protection as a Contributor as well as the protection of the maintainers of the Open Archiver software; it does not change your rights to use your own Contributions for any other purpose. Open Archiver is developed and maintained by LogicLabs OÜ, a private limited company established under the laws of the Republic of Estonia.
|
||||
|
||||
You accept and agree to the following terms and conditions for Your present and future Contributions submitted to LogicLabs OÜ. Except for the license granted herein to LogicLabs OÜ and recipients of software distributed by LogicLabs OÜ, You reserve all right, title, and interest in and to Your Contributions.
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with LogicLabs OÜ. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor.
|
||||
|
||||
"Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to LogicLabs OÜ for inclusion in, or documentation of, any of the products owned or managed by LogicLabs OÜ (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to LogicLabs OÜ or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, LogicLabs OÜ for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution."
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of this Agreement, You grant to LogicLabs OÜ and to recipients of software distributed by LogicLabs OÜ a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of this Agreement, You grant to LogicLabs OÜ and to recipients of software distributed by LogicLabs OÜ a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
4. You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to LogicLabs OÜ, or that your employer has executed a separate Contributor License Agreement with LogicLabs OÜ.
|
||||
|
||||
5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions.
|
||||
|
||||
6. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
|
||||
7. Should You wish to submit work that is not Your original creation, You may submit it to LogicLabs OÜ separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]".
|
||||
|
||||
8. You agree to notify LogicLabs OÜ of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect.
|
||||
25
.github/CLA.md
vendored
Normal file
25
.github/CLA.md
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Contributor License Agreement (CLA)
|
||||
|
||||
This license is for your protection as a Contributor as well as the protection of the maintainers of the Open Archiver software; it does not change your rights to use your own Contributions for any other purpose. In the following, the maintainers of Open Archiver are referred to as "Open Archiver".
|
||||
|
||||
You accept and agree to the following terms and conditions for Your present and future Contributions submitted to "Open Archiver". Except for the license granted herein to Open Archiver and recipients of software distributed by "Open Archiver", You reserve all right, title, and interest in and to Your Contributions.
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with "Open Archiver". For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor.
|
||||
|
||||
"Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to "Open Archiver" for inclusion in, or documentation of, any of the products owned or managed by "Open Archiver" (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to "Open Archiver" or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, "Open Archiver" for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution."
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of this Agreement, You grant to "Open Archiver" and to recipients of software distributed by "Open Archiver" a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of this Agreement, You grant to "Open Archiver" and to recipients of software distributed by "Open Archiver" a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
4. You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to "Open Archiver", or that your employer has executed a separate Contributor License Agreement with "Open Archiver".
|
||||
|
||||
5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions.
|
||||
|
||||
6. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
|
||||
7. Should You wish to submit work that is not Your original creation, You may submit it to "Open Archiver" separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]".
|
||||
|
||||
8. You agree to notify "Open Archiver" of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect.
|
||||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
github: [wayneshn]
|
||||
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**System:**
|
||||
|
||||
- Open Archiver Version:
|
||||
|
||||
**Relevant logs:**
|
||||
Any relevant logs (Redact sensitive information)
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is.
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
32
.github/workflows/cla.yml
vendored
Normal file
32
.github/workflows/cla.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: 'CLA Assistant'
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened, closed, synchronize]
|
||||
|
||||
# explicitly configure permissions, in case your GITHUB_TOKEN workflow permissions are set to read-only in repository settings
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write # this can be 'read' if the signatures are in remote repository
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
CLAAssistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'CLA Assistant'
|
||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||
uses: contributor-assistant/github-action@v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
with:
|
||||
path-to-signatures: 'signatures/version2/cla.json'
|
||||
path-to-document: 'https://github.com/LogicLabs-OU/OpenArchiver/blob/main/.github/CLA-v2.md'
|
||||
branch: 'main'
|
||||
allowlist: 'wayneshn'
|
||||
|
||||
remote-organization-name: 'LogicLabs-OU'
|
||||
remote-repository-name: 'cla-db'
|
||||
43
.github/workflows/deploy-docs.yml
vendored
Normal file
43
.github/workflows/deploy-docs.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Deploy Docs to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/**'
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 10.13.1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build documentation site
|
||||
run: pnpm docs:build
|
||||
|
||||
- name: Create CNAME file
|
||||
run: echo "docs.openarchiver.com" > docs/.vitepress/dist/CNAME
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: docs/.vitepress/dist
|
||||
41
.github/workflows/docker-deployment.yml
vendored
Normal file
41
.github/workflows/docker-deployment.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: docker-deployment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract short SHA
|
||||
id: sha
|
||||
run: echo "sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./apps/open-archiver/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: logiclabshq/open-archiver:${{ steps.sha.outputs.sha }}
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -9,7 +9,6 @@ dist
|
||||
**/meili_data/
|
||||
|
||||
# PNPM
|
||||
pnpm-lock.yaml
|
||||
pnpm-debug.log
|
||||
|
||||
# IDE
|
||||
@@ -21,3 +20,11 @@ pnpm-debug.log
|
||||
|
||||
# Dev
|
||||
.dev
|
||||
|
||||
# Vitepress
|
||||
docs/.vitepress/dist
|
||||
docs/.vitepress/cache
|
||||
|
||||
|
||||
# TS
|
||||
**/tsconfig.tsbuildinfo
|
||||
|
||||
13
.prettierignore
Normal file
13
.prettierignore
Normal file
@@ -0,0 +1,13 @@
|
||||
# Ignore artifacts
|
||||
dist
|
||||
.svelte-kit
|
||||
build
|
||||
node_modules
|
||||
pnpm-lock.yaml
|
||||
meili_data/
|
||||
|
||||
## shadcn installs
|
||||
packages/frontend/src/lib/components/ui/
|
||||
|
||||
# Ignore logs
|
||||
*.log
|
||||
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"trailingComma": "es5",
|
||||
"semi": true,
|
||||
"tabWidth": 4,
|
||||
"printWidth": 100,
|
||||
"plugins": [
|
||||
"prettier-plugin-svelte",
|
||||
"prettier-plugin-tailwindcss"
|
||||
],
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
@@ -16,24 +16,24 @@ We pledge to act and interact in ways that are welcoming, open, and respectful.
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ First off, thank you for considering contributing to Open Archiver! It's people
|
||||
|
||||
Not sure where to start? You can:
|
||||
|
||||
- Look through the [open issues](https://github.com/LogicLabs-OU/OpenArchiver/issues) for bugs or feature requests.
|
||||
- Check the issues labeled `good first issue` for tasks that are a good entry point into the codebase.
|
||||
- Look through the [open issues](https://github.com/LogicLabs-OU/OpenArchiver/issues) for bugs or feature requests.
|
||||
- Check the issues labeled `good first issue` for tasks that are a good entry point into the codebase.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
@@ -29,6 +29,10 @@ If you have an idea for an enhancement, please open an issue to discuss it. This
|
||||
6. **Update the documentation** if your changes require it.
|
||||
7. **Submit a pull request** to the `main` branch of the main repository. Please provide a clear description of the problem and solution. Include the relevant issue number if applicable.
|
||||
|
||||
## Contributor License Agreement
|
||||
|
||||
By submitting a pull request to this repository, you agree to the terms and conditions of our [Contributor License Agreement](./.github/CLA.md)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
This project and everyone participating in it is governed by the [Open Archiver Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior.
|
||||
@@ -37,13 +41,23 @@ This project and everyone participating in it is governed by the [Open Archiver
|
||||
|
||||
### Git Commit Messages
|
||||
|
||||
- Use the present tense ("Add feature" not "Added feature").
|
||||
- Use the imperative mood ("Move cursor to..." not "Moves cursor to...").
|
||||
- Limit the first line to 72 characters or less.
|
||||
- Reference issues and pull requests liberally after the first line.
|
||||
- Use the present tense ("Add feature" not "Added feature").
|
||||
- Use the imperative mood ("Move cursor to..." not "Moves cursor to...").
|
||||
- Limit the first line to 72 characters or less.
|
||||
- Reference issues and pull requests liberally after the first line.
|
||||
|
||||
### TypeScript Styleguide
|
||||
|
||||
- Follow the existing code style.
|
||||
- Use TypeScript's strict mode.
|
||||
- Avoid using `any` as a type. Define clear interfaces and types in the `packages/types` directory.
|
||||
- Follow the existing code style.
|
||||
- Use TypeScript's strict mode.
|
||||
- Avoid using `any` as a type. Define clear interfaces and types in the `packages/types` directory.
|
||||
|
||||
### Formatting
|
||||
|
||||
We use Prettier for code formatting. Before you commit new code, it is necessary to check code format by running this command from the root folder:
|
||||
|
||||
`pnpm run lint`
|
||||
|
||||
If there are any format issues, you can use the following command to fix them
|
||||
|
||||
`pnpm run format`
|
||||
|
||||
140
LICENSE
140
LICENSE
@@ -200,23 +200,23 @@ You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
- **a)** The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
- **b)** The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section 7.
|
||||
This requirement modifies the requirement in section 4 to
|
||||
“keep intact all notices”.
|
||||
- **c)** You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
- **d)** If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
- **a)** The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
- **b)** The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section 7.
|
||||
This requirement modifies the requirement in section 4 to
|
||||
“keep intact all notices”.
|
||||
- **c)** You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
- **d)** If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
@@ -235,42 +235,42 @@ of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
- **a)** Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
- **b)** Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either **(1)** a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or **(2)** access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
- **c)** Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
- **d)** Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
- **e)** Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
- **a)** Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
- **b)** Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either **(1)** a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or **(2)** access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
- **c)** Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
- **d)** Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
- **e)** Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
@@ -344,23 +344,23 @@ Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
- **a)** Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
- **b)** Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
- **c)** Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
- **d)** Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
- **e)** Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
- **f)** Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
- **a)** Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
- **b)** Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
- **c)** Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
- **d)** Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
- **e)** Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
- **f)** Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered “further
|
||||
restrictions” within the meaning of section 10. If the Program as you
|
||||
|
||||
106
README.md
106
README.md
@@ -1,45 +1,79 @@
|
||||
# Open Archiver
|
||||
|
||||
> [!WARNING]
|
||||
> This project is currently in active development and has not yet reached a stable General Availability (GA) release. It is not recommended for use in production environments. We welcome contributions from the community to help us accelerate development and improve the platform. Please see our [Contributing](#contributing) section for more details.
|
||||
[](https://www.docker.com)
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://www.meilisearch.com/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://redis.io)
|
||||
[](https://svelte.dev/)
|
||||
|
||||
**A secure, sovereign, and affordable open-source platform for email archiving and eDiscovery.**
|
||||
**A secure, sovereign, and open-source platform for email archiving.**
|
||||
|
||||
Open Archiver enables individuals and organizations to take control of their digital communication history. It provides a robust, self-hosted solution for ingesting, storing, indexing, and searching emails from major platforms, ensuring a permanent, tamper-proof record of your most critical data, free from vendor lock-in.
|
||||
Open Archiver provides a robust, self-hosted solution for archiving, storing, indexing, and searching emails from major platforms, including Google Workspace (Gmail), Microsoft 365, PST files, as well as generic IMAP-enabled email inboxes. Use Open Archiver to keep a permanent, tamper-proof record of your communication history, free from vendor lock-in.
|
||||
|
||||

|
||||
## Screenshots
|
||||
|
||||
## Vision
|
||||

|
||||
_Dashboard_
|
||||
|
||||
To provide individuals and organizations with a secure, sovereign, and affordable platform to preserve and access their digital communication history.
|
||||

|
||||
_Archived emails_
|
||||
|
||||

|
||||
_Full-text search across all your emails and attachments_
|
||||
|
||||
## Join our community!
|
||||
|
||||
We are committed to building an engaging community around Open Archiver, and we are inviting all of you to join our community on Discord to get real-time support and connect with the team.
|
||||
|
||||
[](https://discord.gg/MTtD7BhuTQ)
|
||||
|
||||
[](https://bsky.app/profile/openarchiver.bsky.social)
|
||||
|
||||
## 🚀 Live demo
|
||||
|
||||
Check out the live demo here: https://demo.openarchiver.com
|
||||
|
||||
Username: demo@openarchiver.com
|
||||
|
||||
Password: openarchiver_demo
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Universal Ingestion**: Connect to Google Workspace, Microsoft 365, and standard IMAP servers to perform initial bulk imports and maintain continuous, real-time synchronization.
|
||||
- **Secure & Efficient Storage**: Emails are stored in the standard `.eml` format. The system uses deduplication and compression to minimize storage costs. All data is encrypted at rest.
|
||||
- **Pluggable Storage Backends**: Start with local filesystem storage and scale to S3-compatible object storage (like AWS S3 or MinIO) as your needs grow.
|
||||
- **Powerful Search & eDiscovery**: A high-performance search engine indexes the full text of emails and attachments (PDF, DOCX, etc.). The intuitive UI supports advanced search operators, filtering, and case management.
|
||||
- **Compliance & Retention**: Define granular retention policies to automatically manage the lifecycle of your data. Place legal holds on communications to prevent deletion during litigation.
|
||||
- **Comprehensive Auditing**: An immutable audit trail logs all system activities, ensuring you have a clear record of who accessed what and when.
|
||||
- **Role-Based Access Control (RBAC)**: Enforce the principle of least privilege with pre-defined roles for Administrators, Auditors, and End Users.
|
||||
- **Universal Ingestion**: Connect to any email provider to perform initial bulk imports and maintain continuous, real-time synchronization. Ingestion sources include:
|
||||
- IMAP connection
|
||||
- Google Workspace
|
||||
- Microsoft 365
|
||||
- PST files
|
||||
- Zipped .eml files
|
||||
- Mbox files
|
||||
|
||||
- **Secure & Efficient Storage**: Emails are stored in the standard `.eml` format. The system uses deduplication and compression to minimize storage costs. All files are encrypted at rest.
|
||||
- **Pluggable Storage Backends**: Support both local filesystem storage and S3-compatible object storage (like AWS S3 or MinIO).
|
||||
- **Powerful Search & eDiscovery**: A high-performance search engine indexes the full text of emails and attachments (PDF, DOCX, etc.).
|
||||
- **Thread discovery**: The ability to discover if an email belongs to a thread/conversation and present the context.
|
||||
- **Compliance & Retention**: Define granular retention policies to automatically manage the lifecycle of your data. Place legal holds on communications to prevent deletion during litigation (TBD).
|
||||
- **File Hash and Encryption**: Email and attachment file hash values are stored in the meta database upon ingestion, meaning any attempt to alter the file content will be identified, ensuring legal and regulatory compliance.
|
||||
- - Each archived email comes with an "Integrity Report" feature that indicates if the files are original.
|
||||
- **Comprehensive Auditing**: An immutable audit trail logs all system activities, ensuring you have a clear record of who accessed what and when.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
Open Archiver is built on a modern, scalable, and maintainable technology stack:
|
||||
|
||||
- **Frontend**: SvelteKit with Svelte 5
|
||||
- **Backend**: Node.js with Express.js & TypeScript
|
||||
- **Job Queue**: BullMQ on Redis for robust, asynchronous processing
|
||||
- **Search Engine**: Meilisearch for blazingly fast and resource-efficient search
|
||||
- **Database**: PostgreSQL for metadata, user management, and audit logs
|
||||
- **Deployment**: Docker Compose for easy, one-command deployment
|
||||
- **Frontend**: SvelteKit with Svelte 5
|
||||
- **Backend**: Node.js with Express.js & TypeScript
|
||||
- **Job Queue**: BullMQ on Redis for robust, asynchronous processing. (We use Valkey as the Redis service in the Docker Compose deployment mode, but you can use Redis as well.)
|
||||
- **Search Engine**: Meilisearch for blazingly fast and resource-efficient search
|
||||
- **Database**: PostgreSQL for metadata, user management, and audit logs
|
||||
- **Deployment**: Docker Compose deployment
|
||||
|
||||
## Getting Started
|
||||
## Deployment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
- A server or local machine with at least 2GB of RAM.
|
||||
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
- A server or local machine with at least 4GB of RAM (2GB of RAM if you use external Postgres, Redis (Valkey) and Meilisearch instances).
|
||||
|
||||
### Installation
|
||||
|
||||
@@ -47,7 +81,7 @@ Open Archiver is built on a modern, scalable, and maintainable technology stack:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/LogicLabs-OU/OpenArchiver.git
|
||||
cd open-archiver
|
||||
cd OpenArchiver
|
||||
```
|
||||
|
||||
2. **Configure your environment:**
|
||||
@@ -57,7 +91,7 @@ Open Archiver is built on a modern, scalable, and maintainable technology stack:
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
You will need to edit the `.env` file to set your database passwords, secret keys, and other essential configuration.
|
||||
You will need to edit the `.env` file to set your admin passwords, secret keys, and other essential configuration. Read the .env.example for how to set up.
|
||||
|
||||
3. **Run the application:**
|
||||
|
||||
@@ -65,21 +99,29 @@ Open Archiver is built on a modern, scalable, and maintainable technology stack:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This command will build the necessary Docker images and start all the services (frontend, backend, database, etc.) in the background.
|
||||
This command will pull the pre-built Docker images and start all the services (frontend, backend, database, etc.) in the background.
|
||||
|
||||
4. **Access the application:**
|
||||
Once the services are running, you can access the Open Archiver web interface by navigating to `http://localhost:3000` in your web browser.
|
||||
|
||||
## Data Source Configuration
|
||||
|
||||
After deploying the application, you will need to configure one or more ingestion sources to begin archiving emails. Follow our detailed guides to connect to your email provider:
|
||||
|
||||
- [Connecting to Google Workspace](https://docs.openarchiver.com/user-guides/email-providers/google-workspace.html)
|
||||
- [Connecting to Microsoft 365](https://docs.openarchiver.com/user-guides/email-providers/imap.html)
|
||||
- [Connecting to a Generic IMAP Server](https://docs.openarchiver.com/user-guides/email-providers/imap.html)
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions from the community! Whether you're a developer, a designer, or just an enthusiast, there are many ways to get involved.
|
||||
We welcome contributions from the community!
|
||||
|
||||
- **Reporting Bugs**: If you find a bug, please open an issue on our GitHub repository.
|
||||
- **Suggesting Enhancements**: Have an idea for a new feature? We'd love to hear it. Open an issue to start the discussion.
|
||||
- **Code Contributions**: If you'd like to contribute code, please fork the repository and submit a pull request.
|
||||
- **Reporting Bugs**: If you find a bug, please open an issue on our GitHub repository.
|
||||
- **Suggesting Enhancements**: Have an idea for a new feature? We'd love to hear it. Open an issue to start the discussion.
|
||||
- **Code Contributions**: If you'd like to contribute code, please fork the repository and submit a pull request.
|
||||
|
||||
Please read our `CONTRIBUTING.md` file for more details on our code of conduct and the process for submitting pull requests.
|
||||
|
||||
## License
|
||||
## 📈 Star History
|
||||
|
||||
This project is licensed under the AGPL-3.0 License.
|
||||
[](https://www.star-history.com/#LogicLabs-OU/OpenArchiver&Date)
|
||||
|
||||
@@ -1,53 +1,57 @@
|
||||
# Dockerfile for Open Archiver
|
||||
# Dockerfile for the OSS version of Open Archiver
|
||||
|
||||
# 1. Build Stage: Install all dependencies and build the project
|
||||
FROM node:22-alpine AS build
|
||||
ARG BASE_IMAGE=node:22-alpine
|
||||
|
||||
# 0. Base Stage: Define all common dependencies and setup
|
||||
FROM ${BASE_IMAGE} AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install -g pnpm
|
||||
|
||||
# Copy manifests and lockfile
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
||||
COPY packages/backend/package.json ./packages/backend/
|
||||
COPY packages/frontend/package.json ./packages/frontend/
|
||||
COPY packages/types/package.json ./packages/types/
|
||||
COPY apps/open-archiver/package.json ./apps/open-archiver/
|
||||
|
||||
# Install all dependencies
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
# 1. Build Stage: Install all dependencies and build the project
|
||||
FROM base AS build
|
||||
COPY packages/frontend/svelte.config.js ./packages/frontend/
|
||||
|
||||
# Install all dependencies.
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --shamefully-hoist --frozen-lockfile --prod=false
|
||||
|
||||
# Copy the rest of the source code
|
||||
COPY . .
|
||||
|
||||
# Build all packages
|
||||
RUN pnpm build
|
||||
# Build the OSS packages.
|
||||
RUN pnpm build:oss
|
||||
|
||||
# 2. Production Stage: Install only production dependencies and copy built artifacts
|
||||
FROM node:22-alpine AS production
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# Copy manifests and lockfile
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
||||
COPY packages/backend/package.json ./packages/backend/
|
||||
COPY packages/frontend/package.json ./packages/frontend/
|
||||
COPY packages/types/package.json ./packages/types/
|
||||
|
||||
# Install only production dependencies
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
FROM base AS production
|
||||
|
||||
# Copy built application from build stage
|
||||
COPY --from=build /app/packages/backend/dist ./packages/backend/dist
|
||||
COPY --from=build /app/packages/frontend/build ./packages/frontend/build
|
||||
COPY --from=build /app/packages/types/dist ./packages/types/dist
|
||||
COPY --from=build /app/packages/backend/drizzle.config.ts ./packages/backend/drizzle.config.ts
|
||||
COPY --from=build /app/packages/backend/src/database/migrations ./packages/backend/src/database/migrations
|
||||
COPY --from=build /app/packages/frontend/build ./packages/frontend/build
|
||||
COPY --from=build /app/packages/types/dist ./packages/types/dist
|
||||
COPY --from=build /app/apps/open-archiver/dist ./apps/open-archiver/dist
|
||||
|
||||
# Copy the entrypoint script and make it executable
|
||||
COPY docker/docker-entrypoint.sh /usr/local/bin/
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 4000
|
||||
EXPOSE 3000
|
||||
|
||||
# Set the entrypoint
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
|
||||
# Start the application
|
||||
CMD ["pnpm", "docker-start"]
|
||||
CMD ["pnpm", "docker-start:oss"]
|
||||
24
apps/open-archiver/index.ts
Normal file
24
apps/open-archiver/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createServer, logger } from '@open-archiver/backend';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function start() {
|
||||
// --- Environment Variable Validation ---
|
||||
const { PORT_BACKEND } = process.env;
|
||||
|
||||
if (!PORT_BACKEND) {
|
||||
throw new Error('Missing required environment variables for the backend: PORT_BACKEND.');
|
||||
}
|
||||
// Create the server instance (passing no modules for the default OSS version)
|
||||
const app = await createServer([]);
|
||||
|
||||
app.listen(PORT_BACKEND, () => {
|
||||
logger.info({}, `✅ Open Archiver (OSS) running on port ${PORT_BACKEND}`);
|
||||
});
|
||||
}
|
||||
|
||||
start().catch((error) => {
|
||||
logger.error({ error }, 'Failed to start the server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
18
apps/open-archiver/package.json
Normal file
18
apps/open-archiver/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "open-archiver-app",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "ts-node-dev --respawn --transpile-only index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@open-archiver/backend": "workspace:*",
|
||||
"dotenv": "^17.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dotenv": "^8.2.3",
|
||||
"ts-node-dev": "^2.0.0"
|
||||
}
|
||||
}
|
||||
8
apps/open-archiver/tsconfig.json
Normal file
8
apps/open-archiver/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["./**/*.ts"],
|
||||
"references": [{ "path": "../../packages/backend" }]
|
||||
}
|
||||
BIN
assets/screenshots/archived-emails.png
Normal file
BIN
assets/screenshots/archived-emails.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 305 KiB |
BIN
assets/screenshots/dashboard-1.png
Normal file
BIN
assets/screenshots/dashboard-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
BIN
assets/screenshots/integrity-report.png
Normal file
BIN
assets/screenshots/integrity-report.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 304 KiB |
BIN
assets/screenshots/job-queue.png
Normal file
BIN
assets/screenshots/job-queue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 259 KiB |
BIN
assets/screenshots/search.png
Normal file
BIN
assets/screenshots/search.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 199 KiB |
@@ -0,0 +1,73 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
open-archiver:
|
||||
image: logiclabshq/open-archiver:latest
|
||||
container_name: open-archiver
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '${PORT_FRONTEND:-3000}:3000' # Frontend
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ${STORAGE_LOCAL_ROOT_PATH}:${STORAGE_LOCAL_ROOT_PATH}
|
||||
depends_on:
|
||||
- postgres
|
||||
- valkey
|
||||
- meilisearch
|
||||
networks:
|
||||
- open-archiver-net
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
container_name: postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-open_archive}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-admin}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
networks:
|
||||
- open-archiver-net
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:8-alpine
|
||||
container_name: valkey
|
||||
restart: unless-stopped
|
||||
command: valkey-server --requirepass ${REDIS_PASSWORD}
|
||||
volumes:
|
||||
- valkeydata:/data
|
||||
networks:
|
||||
- open-archiver-net
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.38
|
||||
container_name: meilisearch
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-aSampleMasterKey}
|
||||
MEILI_SCHEDULE_SNAPSHOT: ${MEILI_SCHEDULE_SNAPSHOT:-86400}
|
||||
volumes:
|
||||
- meilidata:/meili_data
|
||||
networks:
|
||||
- open-archiver-net
|
||||
|
||||
tika:
|
||||
image: apache/tika:3.2.2.0-full
|
||||
container_name: tika
|
||||
restart: always
|
||||
networks:
|
||||
- open-archiver-net
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
driver: local
|
||||
valkeydata:
|
||||
driver: local
|
||||
meilidata:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
open-archiver-net:
|
||||
driver: bridge
|
||||
|
||||
17
docker/docker-entrypoint.sh
Executable file
17
docker/docker-entrypoint.sh
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Exit immediately if a command exits with a non-zero status
|
||||
set -e
|
||||
|
||||
# Run pnpm install to ensure all dependencies, including native addons,
|
||||
# are built for the container's architecture. This is crucial for
|
||||
# multi-platform Docker images, as it prevents "exec format error"
|
||||
# when running on a different architecture than the one used for building.
|
||||
pnpm install --frozen-lockfile --prod
|
||||
|
||||
# Run database migrations before starting the application to prevent
|
||||
# race conditions where the app starts before the database is ready.
|
||||
pnpm db:migrate
|
||||
|
||||
# Execute the main container command
|
||||
exec "$@"
|
||||
122
docs/.vitepress/config.mts
Normal file
122
docs/.vitepress/config.mts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { defineConfig } from 'vitepress';
|
||||
import { useSidebar } from 'vitepress-openapi';
|
||||
import spec from '../api/openapi.json';
|
||||
|
||||
export default defineConfig({
|
||||
head: [
|
||||
[
|
||||
'script',
|
||||
{
|
||||
defer: '',
|
||||
src: 'https://analytics.openarchiver.com/script.js',
|
||||
'data-website-id': '2c8b452e-eab5-4f82-8ead-902d8f8b976f',
|
||||
},
|
||||
],
|
||||
['link', { rel: 'icon', href: '/logo-sq.svg' }],
|
||||
],
|
||||
title: 'Open Archiver Docs',
|
||||
description: 'Official documentation for the Open Archiver project.',
|
||||
themeConfig: {
|
||||
search: {
|
||||
provider: 'local',
|
||||
},
|
||||
logo: {
|
||||
src: '/logo-sq.svg',
|
||||
},
|
||||
nav: [
|
||||
{ text: 'Home', link: '/' },
|
||||
{ text: 'Github', link: 'https://github.com/LogicLabs-OU/OpenArchiver' },
|
||||
{ text: 'Website', link: 'https://openarchiver.com/' },
|
||||
{ text: 'Discord', link: 'https://discord.gg/MTtD7BhuTQ' },
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
text: 'User Guides',
|
||||
items: [
|
||||
{ text: 'Get Started', link: '/' },
|
||||
{ text: 'Installation', link: '/user-guides/installation' },
|
||||
{ text: 'Email Integrity Check', link: '/user-guides/integrity-check' },
|
||||
{
|
||||
text: 'Email Providers',
|
||||
link: '/user-guides/email-providers/',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{
|
||||
text: 'Generic IMAP Server',
|
||||
link: '/user-guides/email-providers/imap',
|
||||
},
|
||||
{
|
||||
text: 'Google Workspace',
|
||||
link: '/user-guides/email-providers/google-workspace',
|
||||
},
|
||||
{
|
||||
text: 'Microsoft 365',
|
||||
link: '/user-guides/email-providers/microsoft-365',
|
||||
},
|
||||
{ text: 'EML Import', link: '/user-guides/email-providers/eml' },
|
||||
{ text: 'PST Import', link: '/user-guides/email-providers/pst' },
|
||||
{ text: 'Mbox Import', link: '/user-guides/email-providers/mbox' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Settings',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{
|
||||
text: 'System',
|
||||
link: '/user-guides/settings/system',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Upgrading and Migration',
|
||||
collapsed: true,
|
||||
items: [
|
||||
{
|
||||
text: 'Upgrading',
|
||||
link: '/user-guides/upgrade-and-migration/upgrade',
|
||||
},
|
||||
{
|
||||
text: 'Meilisearch Upgrade',
|
||||
link: '/user-guides/upgrade-and-migration/meilisearch-upgrade',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'API Reference',
|
||||
items: [
|
||||
{ text: 'Overview', link: '/api/' },
|
||||
{ text: 'Authentication', link: '/api/authentication' },
|
||||
{ text: 'Rate Limiting', link: '/api/rate-limiting' },
|
||||
{ text: 'Auth', link: '/api/auth' },
|
||||
{ text: 'Archived Email', link: '/api/archived-email' },
|
||||
{ text: 'Dashboard', link: '/api/dashboard' },
|
||||
{ text: 'Ingestion', link: '/api/ingestion' },
|
||||
{ text: 'Integrity Check', link: '/api/integrity' },
|
||||
{ text: 'Search', link: '/api/search' },
|
||||
{ text: 'Storage', link: '/api/storage' },
|
||||
{ text: 'Upload', link: '/api/upload' },
|
||||
{ text: 'Jobs', link: '/api/jobs' },
|
||||
{ text: 'Users', link: '/api/users' },
|
||||
{ text: 'IAM', link: '/api/iam' },
|
||||
{ text: 'API Keys', link: '/api/api-keys' },
|
||||
{ text: 'Settings', link: '/api/settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Services',
|
||||
items: [
|
||||
{ text: 'Overview', link: '/services/' },
|
||||
{ text: 'Storage Service', link: '/services/storage-service' },
|
||||
{ text: 'OCR Service', link: '/services/ocr-service' },
|
||||
{
|
||||
text: 'IAM Service',
|
||||
items: [{ text: 'IAM Policies', link: '/services/iam-service/iam-policy' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
19
docs/.vitepress/theme/index.ts
Normal file
19
docs/.vitepress/theme/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import DefaultTheme from 'vitepress/theme';
|
||||
import type { EnhanceAppContext } from 'vitepress';
|
||||
import { theme, useOpenapi } from 'vitepress-openapi/client';
|
||||
import 'vitepress-openapi/dist/style.css';
|
||||
import spec from '../../api/openapi.json';
|
||||
|
||||
export default {
|
||||
...DefaultTheme,
|
||||
enhanceApp({ app, router, siteData }: EnhanceAppContext) {
|
||||
// Delegate to DefaultTheme first
|
||||
DefaultTheme.enhanceApp?.({ app, router, siteData });
|
||||
|
||||
// Install vitepress-openapi theme: registers i18n plugin + all OA components
|
||||
theme.enhanceApp({ app, router, siteData });
|
||||
|
||||
// Initialize the global OpenAPI spec
|
||||
useOpenapi({ spec });
|
||||
},
|
||||
};
|
||||
17
docs/SUMMARY.md
Normal file
17
docs/SUMMARY.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Table of contents
|
||||
|
||||
## User guides
|
||||
|
||||
- [Get started](index.md)
|
||||
- [Installation](user-guides/installation.md)
|
||||
- [email-providers](user-guides/email-providers/index.md)
|
||||
- [Connecting to Google Workspace](user-guides/email-providers/google-workspace.md)
|
||||
- [Connecting to a Generic IMAP Server](user-guides/email-providers/imap.md)
|
||||
- [Connecting to Microsoft 365](user-guides/email-providers/microsoft-365.md)
|
||||
|
||||
---
|
||||
|
||||
- [api](api/index.md)
|
||||
- [Ingestion Sources API Documentation](api/ingestion.md)
|
||||
- [services](services/index.md)
|
||||
- [Pluggable Storage Service (StorageService)](services/storage-service.md)
|
||||
19
docs/api/api-keys.md
Normal file
19
docs/api/api-keys.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
aside: false
|
||||
---
|
||||
|
||||
# API Keys
|
||||
|
||||
Generate and manage API keys for programmatic access to the Open Archiver API. API keys are scoped to the user that created them and carry the same permissions as that user. The raw key value is only shown once at creation time.
|
||||
|
||||
## Generate an API Key
|
||||
|
||||
<OAOperation operationId="generateApiKey" />
|
||||
|
||||
## List API Keys
|
||||
|
||||
<OAOperation operationId="getApiKeys" />
|
||||
|
||||
## Delete an API Key
|
||||
|
||||
<OAOperation operationId="deleteApiKey" />
|
||||
19
docs/api/archived-email.md
Normal file
19
docs/api/archived-email.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
aside: false
|
||||
---
|
||||
|
||||
# Archived Email API
|
||||
|
||||
Endpoints for retrieving and deleting archived emails. All endpoints require authentication and the appropriate `archive` permission.
|
||||
|
||||
## List Emails for an Ingestion Source
|
||||
|
||||
<OAOperation operationId="getArchivedEmails" />
|
||||
|
||||
## Get a Single Email
|
||||
|
||||
<OAOperation operationId="getArchivedEmailById" />
|
||||
|
||||
## Delete an Email
|
||||
|
||||
<OAOperation operationId="deleteArchivedEmail" />
|
||||
19
docs/api/auth.md
Normal file
19
docs/api/auth.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
aside: false
|
||||
---
|
||||
|
||||
# Auth API
|
||||
|
||||
Handles user authentication including initial setup, login, and application setup status.
|
||||
|
||||
## Setup
|
||||
|
||||
<OAOperation operationId="authSetup" />
|
||||
|
||||
## Login
|
||||
|
||||
<OAOperation operationId="authLogin" />
|
||||
|
||||
## Check Setup Status
|
||||
|
||||
<OAOperation operationId="authStatus" />
|
||||
40
docs/api/authentication.md
Normal file
40
docs/api/authentication.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
aside: false
|
||||
---
|
||||
|
||||
# API Authentication
|
||||
|
||||
The API supports two authentication methods. Use whichever fits your use case.
|
||||
|
||||
## Method 1: JWT (User Login)
|
||||
|
||||
Obtain a short-lived JWT by calling `POST /v1/auth/login` with your email and password, then pass it as a Bearer token in the `Authorization` header.
|
||||
|
||||
**Example:**
|
||||
|
||||
```http
|
||||
GET /api/v1/dashboard/stats
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
## Method 2: API Key
|
||||
|
||||
Long-lived API keys are suited for automated scripts and integrations. Create one in **Settings > API Keys**, then pass it in the `X-API-KEY` header.
|
||||
|
||||
**Example:**
|
||||
|
||||
```http
|
||||
GET /api/v1/dashboard/stats
|
||||
X-API-KEY: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
|
||||
```
|
||||
|
||||
### Creating an API Key
|
||||
|
||||
1. Navigate to **Settings > API Keys** in the dashboard.
|
||||
2. Click **"Generate API Key"**.
|
||||
3. Provide a descriptive name and select an expiration period (max 2 years).
|
||||
4. Copy the key immediately — it will not be shown again.
|
||||
|
||||
---
|
||||
|
||||
If the token or API key is missing, expired, or invalid, the API responds with `401 Unauthorized`.
|
||||
27
docs/api/dashboard.md
Normal file
27
docs/api/dashboard.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
aside: false
|
||||
---
|
||||
|
||||
# Dashboard API
|
||||
|
||||
Aggregated statistics and summaries for the dashboard UI. Requires `read:dashboard` permission.
|
||||
|
||||
## Get Stats
|
||||
|
||||
<OAOperation operationId="getDashboardStats" />
|
||||
|
||||
## Get Ingestion History
|
||||
|
||||
<OAOperation operationId="getIngestionHistory" />
|
||||
|
||||
## Get Ingestion Source Summaries
|
||||
|
||||
<OAOperation operationId="getDashboardIngestionSources" />
|
||||
|
||||
## Get Recent Syncs
|
||||
|
||||
<OAOperation operationId="getRecentSyncs" />
|
||||
|
||||
## Get Indexed Email Insights
|
||||
|
||||
<OAOperation operationId="getIndexedInsights" />
|
||||
27
docs/api/iam.md
Normal file
27
docs/api/iam.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
aside: false
|
||||
---
|
||||
|
||||
# IAM API
|
||||
|
||||
Manage Identity and Access Management roles and their CASL policy statements. Role management requires Super Admin (`manage:all`) permission. Reading roles requires `read:roles` permission.
|
||||
|
||||
## List All Roles
|
||||
|
||||
<OAOperation operationId="getRoles" />
|
||||
|
||||
## Create a Role
|
||||
|
||||
<OAOperation operationId="createRole" />
|
||||
|
||||
## Get a Role
|
||||
|
||||
<OAOperation operationId="getRoleById" />
|
||||
|
||||
## Update a Role
|
||||
|
||||
<OAOperation operationId="updateRole" />
|
||||
|
||||
## Delete a Role
|
||||
|
||||
<OAOperation operationId="deleteRole" />
|
||||
22
docs/api/index.md
Normal file
22
docs/api/index.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
aside: false
|
||||
---
|
||||
|
||||
# API Overview
|
||||
|
||||
Welcome to the Open Archiver API documentation. This section provides detailed information about the available API endpoints.
|
||||
|
||||
All API endpoints are prefixed with `/api/v1`.
|
||||
|
||||
## Authentication
|
||||
|
||||
Before making requests to protected endpoints, you must authenticate with the API. See the [Authentication Guide](./authentication.md) for details on how to obtain and use API tokens.
|
||||
|
||||
## API Services
|
||||
|
||||
- [**Auth Service**](./auth.md): Handles user authentication.
|
||||
- [**Archived Email Service**](./archived-email.md): Manages archived emails.
|
||||
- [**Dashboard Service**](./dashboard.md): Provides data for the main dashboard.
|
||||
- [**Ingestion Service**](./ingestion.md): Manages email ingestion sources.
|
||||
- [**Search Service**](./search.md): Handles email search functionality.
|
||||
- [**Storage Service**](./storage.md): Manages file storage and downloads.
|
||||
@@ -1,206 +1,43 @@
|
||||
# Ingestion Sources API Documentation
|
||||
|
||||
A comprehensive guide to using the Ingestion Sources API.
|
||||
|
||||
**Base Path:** `/v1/ingestion-sources`
|
||||
|
||||
---
|
||||
aside: false
|
||||
---
|
||||
|
||||
## Authentication
|
||||
# Ingestion API
|
||||
|
||||
All endpoints in this API are protected and require authentication. Requests must include an `Authorization` header containing a valid Bearer token. This can be a JWT obtained from the login endpoint or a `SUPER_API_KEY` for administrative tasks.
|
||||
Manage ingestion sources — the configured connections to email providers (Google Workspace, Microsoft 365, IMAP, and file imports). Credentials are never returned in responses.
|
||||
|
||||
**Header Example:**
|
||||
`Authorization: Bearer <YOUR_JWT_OR_SUPER_API_KEY>`
|
||||
## Create an Ingestion Source
|
||||
|
||||
---
|
||||
<OAOperation operationId="createIngestionSource" />
|
||||
|
||||
## Core Concepts
|
||||
## List Ingestion Sources
|
||||
|
||||
### Ingestion Providers
|
||||
<OAOperation operationId="listIngestionSources" />
|
||||
|
||||
The `provider` field determines the type of email source. Each provider requires a different configuration object, for example:
|
||||
## Get an Ingestion Source
|
||||
|
||||
- `google_workspace`: For connecting to Google Workspace accounts via OAuth 2.0.
|
||||
- `microsoft_365`: For connecting to Microsoft 365 accounts via OAuth 2.0.
|
||||
- `generic_imap`: For connecting to any email server that supports IMAP.
|
||||
<OAOperation operationId="getIngestionSourceById" />
|
||||
|
||||
### Ingestion Status
|
||||
## Update an Ingestion Source
|
||||
|
||||
The `status` field tracks the state of the ingestion source.
|
||||
<OAOperation operationId="updateIngestionSource" />
|
||||
|
||||
- `pending_auth`: The source has been created but requires user authorization (OAuth flow).
|
||||
- `active`: The source is authenticated and ready to sync.
|
||||
- `syncing`: An import job is currently in progress.
|
||||
- `importing`: initial syncing in progress
|
||||
- `paused`: The source is temporarily disabled.
|
||||
- `error`: An error occurred during the last sync.
|
||||
## Delete an Ingestion Source
|
||||
|
||||
---
|
||||
<OAOperation operationId="deleteIngestionSource" />
|
||||
|
||||
## 1. Create Ingestion Source
|
||||
## Trigger Initial Import
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/`
|
||||
- **Description:** Registers a new source for email ingestion. The `providerConfig` will vary based on the selected `provider`.
|
||||
<OAOperation operationId="triggerInitialImport" />
|
||||
|
||||
#### Request Body (`CreateIngestionSourceDto`)
|
||||
## Pause an Ingestion Source
|
||||
|
||||
- `name` (string, required): A user-friendly name for the source (e.g., "Marketing Department G-Suite").
|
||||
- `provider` (string, required): One of `google_workspace`, `microsoft_365`, or `generic_imap`.
|
||||
- `providerConfig` (object, required): Configuration specific to the provider.
|
||||
<OAOperation operationId="pauseIngestionSource" />
|
||||
|
||||
##### `providerConfig` for `google_workspace` / `microsoft_365`
|
||||
## Force Sync
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Corporate Google Workspace",
|
||||
"provider": "google_workspace",
|
||||
"providerConfig": {
|
||||
"clientId": "your-oauth-client-id.apps.googleusercontent.com",
|
||||
"clientSecret": "your-super-secret-client-secret",
|
||||
"redirectUri": "https://yourapp.com/oauth/google/callback"
|
||||
}
|
||||
}
|
||||
```
|
||||
<OAOperation operationId="triggerForceSync" />
|
||||
|
||||
##### `providerConfig` for `generic_imap`
|
||||
## Unmerge an Ingestion Source
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Legacy IMAP Server",
|
||||
"provider": "generic_imap",
|
||||
"providerConfig": {
|
||||
"host": "imap.example.com",
|
||||
"port": 993,
|
||||
"secure": true,
|
||||
"username": "archive-user",
|
||||
"password": "imap-password"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Responses
|
||||
|
||||
- **Success (`201 Created`):** Returns the full `IngestionSource` object, which now includes a system-generated `id` and default status.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
|
||||
"name": "Corporate Google Workspace",
|
||||
"provider": "google_workspace",
|
||||
"status": "pending_auth",
|
||||
"createdAt": "2025-07-11T12:00:00.000Z",
|
||||
"updatedAt": "2025-07-11T12:00:00.000Z",
|
||||
"providerConfig": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
- **Error (`500 Internal Server Error`):** Indicates a server-side problem during creation.
|
||||
|
||||
---
|
||||
|
||||
## 2. Get All Ingestion Sources
|
||||
|
||||
- **Method:** `GET`
|
||||
- **Path:** `/`
|
||||
- **Description:** Retrieves a list of all configured ingestion sources for the organization.
|
||||
|
||||
#### Responses
|
||||
|
||||
- **Success (`200 OK`):** Returns an array of `IngestionSource` objects.
|
||||
|
||||
- **Error (`500 Internal Server Error`):** Indicates a server-side problem.
|
||||
|
||||
---
|
||||
|
||||
## 3. Get Ingestion Source by ID
|
||||
|
||||
- **Method:** `GET`
|
||||
- **Path:** `/:id`
|
||||
- **Description:** Fetches the details of a specific ingestion source.
|
||||
|
||||
#### URL Parameters
|
||||
|
||||
- `id` (string, required): The UUID of the ingestion source.
|
||||
|
||||
#### Responses
|
||||
|
||||
- **Success (`200 OK`):** Returns the corresponding `IngestionSource` object.
|
||||
|
||||
- **Error (`404 Not Found`):** Returned if no source with the given ID exists.
|
||||
- **Error (`500 Internal Server Error`):** Indicates a server-side problem.
|
||||
|
||||
---
|
||||
|
||||
## 4. Update Ingestion Source
|
||||
|
||||
- **Method:** `PUT`
|
||||
- **Path:** `/:id`
|
||||
- **Description:** Modifies an existing ingestion source. This is useful for changing the name, pausing a source, or updating its configuration.
|
||||
|
||||
#### URL Parameters
|
||||
|
||||
- `id` (string, required): The UUID of the ingestion source to update.
|
||||
|
||||
#### Request Body (`UpdateIngestionSourceDto`)
|
||||
|
||||
All fields are optional. Only include the fields you want to change.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Marketing Dept G-Suite (Paused)",
|
||||
"status": "paused"
|
||||
}
|
||||
```
|
||||
|
||||
#### Responses
|
||||
|
||||
- **Success (`200 OK`):** Returns the complete, updated `IngestionSource` object.
|
||||
|
||||
- **Error (`404 Not Found`):** Returned if no source with the given ID exists.
|
||||
- **Error (`500 Internal Server Error`):** Indicates a server-side problem.
|
||||
|
||||
---
|
||||
|
||||
## 5. Delete Ingestion Source
|
||||
|
||||
- **Method:** `DELETE`
|
||||
- **Path:** `/:id`
|
||||
- **Description:** Permanently removes an ingestion source. This action cannot be undone.
|
||||
|
||||
#### URL Parameters
|
||||
|
||||
- `id` (string, required): The UUID of the ingestion source to delete.
|
||||
|
||||
#### Responses
|
||||
|
||||
- **Success (`204 No Content`):** Indicates successful deletion with no body content.
|
||||
|
||||
- **Error (`404 Not Found`):** Returned if no source with the given ID exists.
|
||||
- **Error (`500 Internal Server Error`):** Indicates a server-side problem.
|
||||
|
||||
---
|
||||
|
||||
## 6. Trigger Initial Import
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/:id/sync`
|
||||
- **Description:** Initiates the email import process for a given source. This is an asynchronous operation that enqueues a background job and immediately returns a response. The status of the source will be updated to `importing`.
|
||||
|
||||
#### URL Parameters
|
||||
|
||||
- `id` (string, required): The UUID of the ingestion source to sync.
|
||||
|
||||
#### Responses
|
||||
|
||||
- **Success (`202 Accepted`):** Confirms that the sync request has been accepted for processing.
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Initial import triggered successfully."
|
||||
}
|
||||
```
|
||||
|
||||
- **Error (`404 Not Found`):** Returned if no source with the given ID exists.
|
||||
- **Error (`500 Internal Server Error`):** Indicates a server-side problem.
|
||||
<OAOperation operationId="unmergeIngestionSource" />
|
||||
|
||||
11
docs/api/integrity.md
Normal file
11
docs/api/integrity.md
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
aside: false
|
||||
---
|
||||
|
||||
# Integrity Check API
|
||||
|
||||
Verify the SHA-256 hash of an archived email and all its attachments against the hashes stored at archival time.
|
||||
|
||||
## Check Email Integrity
|
||||
|
||||
<OAOperation operationId="checkIntegrity" />
|
||||
20
docs/api/jobs.md
Normal file
20
docs/api/jobs.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
aside: false
|
||||
---
|
||||
|
||||
# Jobs API
|
||||
|
||||
Monitor BullMQ job queues for asynchronous tasks such as email ingestion, indexing, and sync scheduling. Requires Super Admin (`manage:all`) permission.
|
||||
|
||||
There are two queues:
|
||||
|
||||
- **`ingestion`** — handles all email ingestion and sync jobs (`initial-import`, `continuous-sync`, `process-mailbox`, `sync-cycle-finished`, `schedule-continuous-sync`)
|
||||
- **`indexing`** — handles batched Meilisearch document indexing (`index-email-batch`)
|
||||
|
||||
## List All Queues
|
||||
|
||||
<OAOperation operationId="getQueues" />
|
||||
|
||||
## Get Jobs in a Queue
|
||||
|
||||
<OAOperation operationId="getQueueJobs" />
|
||||
3301
docs/api/openapi.json
Normal file
3301
docs/api/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
55
docs/api/rate-limiting.md
Normal file
55
docs/api/rate-limiting.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
aside: false
|
||||
---
|
||||
|
||||
# Rate Limiting
|
||||
|
||||
The API implements rate limiting as a security measure to protect your instance from denial-of-service (DoS) and brute-force attacks. This is a crucial feature for maintaining the security and stability of the application.
|
||||
|
||||
## How It Works
|
||||
|
||||
The rate limiter restricts the number of requests an IP address can make within a specific time frame. These limits are configurable via environment variables to suit your security needs.
|
||||
|
||||
By default, the limits are:
|
||||
|
||||
- **100 requests** per **1 minute** per IP address.
|
||||
|
||||
If this limit is exceeded, the API will respond with an HTTP `429 Too Many Requests` status code.
|
||||
|
||||
### Response Body
|
||||
|
||||
When an IP address is rate-limited, the API will return a JSON response with the following format:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 429,
|
||||
"message": "Too many requests from this IP, please try again after 15 minutes"
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
You can customize the rate-limiting settings by setting the following environment variables in your `.env` file:
|
||||
|
||||
- `RATE_LIMIT_WINDOW_MS`: The time window in milliseconds. Defaults to `60000` (1 minute).
|
||||
- `RATE_LIMIT_MAX_REQUESTS`: The maximum number of requests allowed per IP address within the time window. Defaults to `100`.
|
||||
|
||||
## Handling Rate Limits
|
||||
|
||||
If you are developing a client that interacts with the API, you should handle rate limiting gracefully:
|
||||
|
||||
1. **Check the Status Code**: Monitor for a `429` HTTP status code in responses.
|
||||
2. **Implement a Retry Mechanism**: When you receive a `429` response, it is best practice to wait before retrying the request. Implementing an exponential backoff strategy is recommended.
|
||||
3. **Check Headers**: The response will include the following standard headers to help you manage your request rate:
|
||||
- `RateLimit-Limit`: The maximum number of requests allowed in the current window.
|
||||
- `RateLimit-Remaining`: The number of requests you have left in the current window.
|
||||
- `RateLimit-Reset`: The time when the rate limit window will reset, in UTC epoch seconds.
|
||||
|
||||
## Excluded Endpoints
|
||||
|
||||
Certain essential endpoints are excluded from rate limiting to ensure the application's UI remains responsive. These are:
|
||||
|
||||
- `/auth/status`
|
||||
- `/settings/system`
|
||||
|
||||
These endpoints can be called as needed without affecting your rate limit count.
|
||||
11
docs/api/search.md
Normal file
11
docs/api/search.md
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
aside: false
|
||||
---
|
||||
|
||||
# Search API
|
||||
|
||||
Full-text search over indexed archived emails, powered by Meilisearch.
|
||||
|
||||
## Search Emails
|
||||
|
||||
<OAOperation operationId="searchEmails" />
|
||||
15
docs/api/settings.md
Normal file
15
docs/api/settings.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
aside: false
|
||||
---
|
||||
|
||||
# Settings API
|
||||
|
||||
Read and update system-wide configuration. The `GET` endpoint is public. The `PUT` endpoint requires `manage:settings` permission.
|
||||
|
||||
## Get System Settings
|
||||
|
||||
<OAOperation operationId="getSystemSettings" />
|
||||
|
||||
## Update System Settings
|
||||
|
||||
<OAOperation operationId="updateSystemSettings" />
|
||||
11
docs/api/storage.md
Normal file
11
docs/api/storage.md
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
aside: false
|
||||
---
|
||||
|
||||
# Storage API
|
||||
|
||||
Download files from the configured storage backend (local filesystem or S3-compatible). Requires `read:archive` permission.
|
||||
|
||||
## Download a File
|
||||
|
||||
<OAOperation operationId="downloadFile" />
|
||||
11
docs/api/upload.md
Normal file
11
docs/api/upload.md
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
aside: false
|
||||
---
|
||||
|
||||
# Upload API
|
||||
|
||||
Upload files (PST, EML, MBOX) to temporary storage before creating a file-based ingestion source. The returned `filePath` should be passed as `uploadedFilePath` in the ingestion source `providerConfig`.
|
||||
|
||||
## Upload a File
|
||||
|
||||
<OAOperation operationId="uploadFile" />
|
||||
39
docs/api/users.md
Normal file
39
docs/api/users.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
aside: false
|
||||
---
|
||||
|
||||
# Users API
|
||||
|
||||
Manage user accounts. Creating, updating, and deleting users requires Super Admin (`manage:all`) permission.
|
||||
|
||||
## List All Users
|
||||
|
||||
<OAOperation operationId="getUsers" />
|
||||
|
||||
## Create a User
|
||||
|
||||
<OAOperation operationId="createUser" />
|
||||
|
||||
## Get a User
|
||||
|
||||
<OAOperation operationId="getUser" />
|
||||
|
||||
## Update a User
|
||||
|
||||
<OAOperation operationId="updateUser" />
|
||||
|
||||
## Delete a User
|
||||
|
||||
<OAOperation operationId="deleteUser" />
|
||||
|
||||
## Get Current User Profile
|
||||
|
||||
<OAOperation operationId="getProfile" />
|
||||
|
||||
## Update Current User Profile
|
||||
|
||||
<OAOperation operationId="updateProfile" />
|
||||
|
||||
## Update Password
|
||||
|
||||
<OAOperation operationId="updatePassword" />
|
||||
78
docs/enterprise/audit-log/api.md
Normal file
78
docs/enterprise/audit-log/api.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Audit Log: API Endpoints
|
||||
|
||||
The audit log feature exposes two API endpoints for retrieving and verifying audit log data. Both endpoints require authentication and are only accessible to users with the appropriate permissions.
|
||||
|
||||
## Get Audit Logs
|
||||
|
||||
Retrieves a paginated list of audit log entries, with support for filtering and sorting.
|
||||
|
||||
- **Endpoint:** `GET /api/v1/enterprise/audit-logs`
|
||||
- **Method:** `GET`
|
||||
- **Authentication:** Required
|
||||
|
||||
### Query Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| ------------ | -------- | --------------------------------------------------------------------------- |
|
||||
| `page` | `number` | The page number to retrieve. Defaults to `1`. |
|
||||
| `limit` | `number` | The number of entries to retrieve per page. Defaults to `20`. |
|
||||
| `startDate` | `date` | The start date for the date range filter. |
|
||||
| `endDate` | `date` | The end date for the date range filter. |
|
||||
| `actor` | `string` | The actor identifier to filter by. |
|
||||
| `actionType` | `string` | The action type to filter by (e.g., `LOGIN`, `CREATE`). |
|
||||
| `sort` | `string` | The sort order for the results. Can be `asc` or `desc`. Defaults to `desc`. |
|
||||
|
||||
### Response Body
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"previousHash": null,
|
||||
"timestamp": "2025-10-03T00:00:00.000Z",
|
||||
"actorIdentifier": "e8026a75-b58a-4902-8858-eb8780215f82",
|
||||
"actorIp": "::1",
|
||||
"actionType": "LOGIN",
|
||||
"targetType": "User",
|
||||
"targetId": "e8026a75-b58a-4902-8858-eb8780215f82",
|
||||
"details": {},
|
||||
"currentHash": "..."
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"total": 100,
|
||||
"page": 1,
|
||||
"limit": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Verify Audit Log Integrity
|
||||
|
||||
Initiates a verification process to check the integrity of the entire audit log chain.
|
||||
|
||||
- **Endpoint:** `POST /api/v1/enterprise/audit-logs/verify`
|
||||
- **Method:** `POST`
|
||||
- **Authentication:** Required
|
||||
|
||||
### Response Body
|
||||
|
||||
**Success**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"message": "Audit log integrity verified successfully."
|
||||
}
|
||||
```
|
||||
|
||||
**Failure**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"message": "Audit log chain is broken!",
|
||||
"logId": 123
|
||||
}
|
||||
```
|
||||
31
docs/enterprise/audit-log/audit-service.md
Normal file
31
docs/enterprise/audit-log/audit-service.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Audit Log: Backend Implementation
|
||||
|
||||
The backend implementation of the audit log is handled by the `AuditService`, located in `packages/backend/src/services/AuditService.ts`. This service encapsulates all the logic for creating, retrieving, and verifying audit log entries.
|
||||
|
||||
## Hashing and Verification Logic
|
||||
|
||||
The core of the audit log's immutability lies in its hashing and verification logic.
|
||||
|
||||
### Hash Calculation
|
||||
|
||||
The `calculateHash` method is responsible for generating a SHA-256 hash of a log entry. To ensure consistency, it performs the following steps:
|
||||
|
||||
1. **Canonical Object Creation:** It constructs a new object with a fixed property order, ensuring that the object's structure is always the same.
|
||||
2. **Timestamp Normalization:** It converts the `timestamp` to milliseconds since the epoch (`getTime()`) to avoid any precision-related discrepancies between the application and the database.
|
||||
3. **Canonical Stringification:** It uses a custom `canonicalStringify` function to create a JSON string representation of the object. This function sorts the object keys, ensuring that the output is always the same, regardless of the in-memory property order.
|
||||
4. **Hash Generation:** It computes a SHA-256 hash of the canonical string.
|
||||
|
||||
### Verification Process
|
||||
|
||||
The `verifyAuditLog` method is designed to be highly scalable and efficient, even with millions of log entries. It processes the logs in manageable chunks (e.g., 1000 at a time) to avoid loading the entire table into memory.
|
||||
|
||||
The verification process involves the following steps:
|
||||
|
||||
1. **Iterative Processing:** It fetches the logs in batches within a `while` loop.
|
||||
2. **Chain Verification:** For each log entry, it compares the `previousHash` with the `currentHash` of the preceding log. If they do not match, the chain is broken, and the verification fails.
|
||||
3. **Hash Recalculation:** It recalculates the hash of the current log entry using the same `calculateHash` method used during creation.
|
||||
4. **Integrity Check:** It compares the recalculated hash with the `currentHash` stored in the database. If they do not match, the log entry has been tampered with, and the verification fails.
|
||||
|
||||
## Service Integration
|
||||
|
||||
The `AuditService` is integrated into the application through the `AuditLogModule` (`packages/enterprise/src/modules/audit-log/audit-log.module.ts`), which registers the API routes for the audit log feature. The service's `createAuditLog` method is called from various other services throughout the application to record significant events.
|
||||
39
docs/enterprise/audit-log/guide.md
Normal file
39
docs/enterprise/audit-log/guide.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Audit Log: User Interface
|
||||
|
||||
The audit log user interface provides a comprehensive view of all significant events that have occurred within the Open Archiver system. It is designed to be intuitive and user-friendly, allowing administrators to easily monitor and review system activity.
|
||||
|
||||
## Viewing Audit Logs
|
||||
|
||||
The main audit log page displays a table of log entries, with the following columns:
|
||||
|
||||
- **Timestamp:** The date and time of the event.
|
||||
- **Actor:** The identifier of the user or system process that performed the action.
|
||||
- **IP Address:** The IP address from which the action was initiated.
|
||||
- **Action:** The type of action performed, displayed as a color-coded badge for easy identification.
|
||||
- **Target Type:** The type of resource that was affected.
|
||||
- **Target ID:** The unique identifier of the affected resource.
|
||||
- **Details:** A truncated preview of the event's details. The full JSON object is displayed in a pop-up card on hover.
|
||||
|
||||
## Filtering and Sorting
|
||||
|
||||
The table can be sorted by timestamp by clicking the "Timestamp" header. This allows you to view the logs in either chronological or reverse chronological order.
|
||||
|
||||
## Pagination
|
||||
|
||||
Pagination controls are available below the table, allowing you to navigate through the entire history of audit log entries.
|
||||
|
||||
## Verifying Log Integrity
|
||||
|
||||
The "Verify Log Integrity" button allows you to initiate a verification process to check the integrity of the entire audit log chain. This process recalculates the hash of each log entry and compares it to the stored hash, ensuring that the cryptographic chain is unbroken and no entries have been tampered with.
|
||||
|
||||
### Verification Responses
|
||||
|
||||
- **Success:** A success notification is displayed, confirming that the audit log integrity has been verified successfully. This means that the log chain is complete and no entries have been tampered with.
|
||||
|
||||
- **Failure:** An error notification is displayed, indicating that the audit log chain is broken or an entry has been tampered with. The notification will include the ID of the log entry where the issue was detected. There are two types of failures:
|
||||
- **Audit log chain is broken:** This means that the `previousHash` of a log entry does not match the `currentHash` of the preceding entry. This indicates that one or more log entries may have been deleted or inserted into the chain.
|
||||
- **Audit log entry is tampered!:** This means that the recalculated hash of a log entry does not match its stored `currentHash`. This indicates that the data within the log entry has been altered.
|
||||
|
||||
## Viewing Log Details
|
||||
|
||||
You can view the full details of any log entry by clicking on its row in the table. This will open a dialog containing all the information associated with the log entry, including the previous and current hashes.
|
||||
27
docs/enterprise/audit-log/index.md
Normal file
27
docs/enterprise/audit-log/index.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Audit Log
|
||||
|
||||
The Audit Log is an enterprise-grade feature designed to provide a complete, immutable, and verifiable record of every significant action that occurs within the Open Archiver system. Its primary purpose is to ensure compliance with strict regulatory standards, such as the German GoBD, by establishing a tamper-proof chain of evidence for all activities.
|
||||
|
||||
## Core Principles
|
||||
|
||||
To fulfill its compliance and security functions, the audit log adheres to the following core principles:
|
||||
|
||||
### 1. Immutability
|
||||
|
||||
Every log entry is cryptographically chained to the previous one. Each new entry contains a SHA-256 hash of the preceding entry's hash, creating a verifiable chain. Any attempt to alter or delete a past entry would break this chain and be immediately detectable through the verification process.
|
||||
|
||||
### 2. Completeness
|
||||
|
||||
The system is designed to log every significant event without exception. This includes not only user-initiated actions (like logins, searches, and downloads) but also automated system processes, such as data ingestion and policy-based deletions.
|
||||
|
||||
### 3. Attribution
|
||||
|
||||
Each log entry is unambiguously linked to the actor that initiated the event. This could be a specific authenticated user, an external auditor, or an automated system process. The actor's identifier and source IP address are recorded to ensure full traceability.
|
||||
|
||||
### 4. Clarity and Detail
|
||||
|
||||
Log entries are structured to be detailed and human-readable, providing sufficient context for an auditor to understand the event without needing specialized system knowledge. This includes the action performed, the target resource affected, and a JSON object with specific, contextual details of the event.
|
||||
|
||||
### 5. Verifiability
|
||||
|
||||
The integrity of the entire audit log can be verified at any time. A dedicated process iterates through the logs from the beginning, recalculating the hash of each entry and comparing it to the stored hash, ensuring the cryptographic chain is unbroken and no entries have been tampered with.
|
||||
454
docs/enterprise/legal-holds/api.md
Normal file
454
docs/enterprise/legal-holds/api.md
Normal file
@@ -0,0 +1,454 @@
|
||||
# Legal Holds: API Endpoints
|
||||
|
||||
The legal holds feature exposes a RESTful API for managing holds and linking them to archived emails. All endpoints require authentication and appropriate permissions as specified below.
|
||||
|
||||
**Base URL:** `/api/v1/enterprise/legal-holds`
|
||||
|
||||
All endpoints also require the `LEGAL_HOLDS` feature to be enabled in the enterprise license.
|
||||
|
||||
---
|
||||
|
||||
## Hold Management Endpoints
|
||||
|
||||
### List All Holds
|
||||
|
||||
Retrieves all legal holds ordered by creation date ascending, each annotated with the count of currently linked emails.
|
||||
|
||||
- **Endpoint:** `GET /holds`
|
||||
- **Method:** `GET`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `manage:all`
|
||||
|
||||
#### Response Body
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"name": "Project Titan Litigation — 2026",
|
||||
"reason": "Preservation order received 2026-01-15 re: IP dispute",
|
||||
"isActive": true,
|
||||
"caseId": null,
|
||||
"emailCount": 4821,
|
||||
"createdAt": "2026-01-15T10:30:00.000Z",
|
||||
"updatedAt": "2026-01-15T10:30:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
|
||||
"name": "SEC Investigation Q3 2025",
|
||||
"reason": null,
|
||||
"isActive": false,
|
||||
"caseId": "c3d4e5f6-a7b8-9012-cdef-345678901234",
|
||||
"emailCount": 310,
|
||||
"createdAt": "2025-09-01T08:00:00.000Z",
|
||||
"updatedAt": "2025-11-20T16:45:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Hold by ID
|
||||
|
||||
Retrieves a single legal hold by its UUID.
|
||||
|
||||
- **Endpoint:** `GET /holds/:id`
|
||||
- **Method:** `GET`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `manage:all`
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------ | ---------------------------- |
|
||||
| `id` | `uuid` | The UUID of the hold to get. |
|
||||
|
||||
#### Response
|
||||
|
||||
Returns a single hold object (same shape as the list endpoint), or `404` if not found.
|
||||
|
||||
---
|
||||
|
||||
### Create Hold
|
||||
|
||||
Creates a new legal hold. Holds are always created in the **active** state.
|
||||
|
||||
- **Endpoint:** `POST /holds`
|
||||
- **Method:** `POST`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `manage:all`
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| -------- | -------- | -------- | -------------------------------------------------------------- |
|
||||
| `name` | `string` | Yes | Unique hold name. Max 255 characters. |
|
||||
| `reason` | `string` | No | Legal basis or description for the hold. Max 2 000 characters. |
|
||||
| `caseId` | `uuid` | No | Optional UUID of an `ediscovery_cases` record to link to. |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Project Titan Litigation — 2026",
|
||||
"reason": "Preservation notice received from outside counsel on 2026-01-15 regarding IP dispute with ExCorp.",
|
||||
"caseId": null
|
||||
}
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
- **`201 Created`** — Returns the created hold object with `emailCount: 0`.
|
||||
- **`409 Conflict`** — A hold with this name already exists.
|
||||
- **`422 Unprocessable Entity`** — Validation errors.
|
||||
|
||||
---
|
||||
|
||||
### Update Hold
|
||||
|
||||
Updates the name, reason, or `isActive` state of a hold. Only the fields provided in the request body are modified.
|
||||
|
||||
- **Endpoint:** `PUT /holds/:id`
|
||||
- **Method:** `PUT`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `manage:all`
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------ | ------------------------------- |
|
||||
| `id` | `uuid` | The UUID of the hold to update. |
|
||||
|
||||
#### Request Body
|
||||
|
||||
All fields are optional. At least one must be provided.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------- | --------- | --------------------------------------------------- |
|
||||
| `name` | `string` | New hold name. Max 255 characters. |
|
||||
| `reason` | `string` | Updated reason/description. Max 2 000 characters. |
|
||||
| `isActive` | `boolean` | Set to `false` to deactivate, `true` to reactivate. |
|
||||
|
||||
#### Example — Deactivate a Hold
|
||||
|
||||
```json
|
||||
{
|
||||
"isActive": false
|
||||
}
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
- **`200 OK`** — Returns the updated hold object.
|
||||
- **`404 Not Found`** — Hold with the given ID does not exist.
|
||||
- **`422 Unprocessable Entity`** — Validation errors.
|
||||
|
||||
> **Important:** Setting `isActive` to `false` immediately lifts deletion immunity from all emails solely protected by this hold. The next lifecycle worker cycle will evaluate those emails against retention labels and policies.
|
||||
|
||||
---
|
||||
|
||||
### Delete Hold
|
||||
|
||||
Permanently deletes a legal hold and (via database CASCADE) all associated `email_legal_holds` rows.
|
||||
|
||||
- **Endpoint:** `DELETE /holds/:id`
|
||||
- **Method:** `DELETE`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `manage:all`
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------ | ------------------------------- |
|
||||
| `id` | `uuid` | The UUID of the hold to delete. |
|
||||
|
||||
#### Response
|
||||
|
||||
- **`204 No Content`** — Hold successfully deleted.
|
||||
- **`404 Not Found`** — Hold with the given ID does not exist.
|
||||
- **`409 Conflict`** — The hold is currently active. Deactivate it first by calling `PUT /holds/:id` with `{ "isActive": false }`.
|
||||
|
||||
> **Security note:** Active holds cannot be deleted. This requirement forces an explicit, auditable deactivation step before the hold record is removed.
|
||||
|
||||
---
|
||||
|
||||
## Bulk Operations
|
||||
|
||||
### Bulk Apply Hold via Search Query
|
||||
|
||||
Applies a legal hold to **all emails matching a Meilisearch query**. The operation is asynchronous-safe: the UI fires the request and the server processes results in pages of 1 000, so even very large result sets do not time out.
|
||||
|
||||
- **Endpoint:** `POST /holds/:id/bulk-apply`
|
||||
- **Method:** `POST`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `manage:all`
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------ | ------------------------------ |
|
||||
| `id` | `uuid` | The UUID of the hold to apply. |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------- | -------- | -------- | ------------------------------------------------- |
|
||||
| `searchQuery` | `object` | Yes | A Meilisearch query object (see structure below). |
|
||||
|
||||
##### `searchQuery` Object
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------------ | -------- | -------- | ------------------------------------------------------------------- |
|
||||
| `query` | `string` | Yes | Full-text search string. Pass `""` to match all documents. |
|
||||
| `filters` | `object` | No | Key-value filter object (e.g., `{ "from": "user@corp.com" }`). |
|
||||
| `matchingStrategy` | `string` | No | Meilisearch matching strategy: `"last"`, `"all"`, or `"frequency"`. |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```json
|
||||
{
|
||||
"searchQuery": {
|
||||
"query": "Project Titan confidential",
|
||||
"filters": {
|
||||
"from": "john.doe@acme.com",
|
||||
"startDate": "2023-01-01",
|
||||
"endDate": "2025-12-31"
|
||||
},
|
||||
"matchingStrategy": "all"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Response Body
|
||||
|
||||
```json
|
||||
{
|
||||
"legalHoldId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"emailsLinked": 1247,
|
||||
"queryUsed": {
|
||||
"query": "Project Titan confidential",
|
||||
"filters": {
|
||||
"from": "john.doe@acme.com",
|
||||
"startDate": "2023-01-01",
|
||||
"endDate": "2025-12-31"
|
||||
},
|
||||
"matchingStrategy": "all"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `emailsLinked` — The number of emails **newly** linked to the hold by this operation. Emails already linked to this hold are not counted.
|
||||
- `queryUsed` — The exact query JSON that was executed, mirroring what was written to the audit log for GoBD proof of scope.
|
||||
|
||||
#### Response Codes
|
||||
|
||||
- **`200 OK`** — Operation completed. Returns `emailsLinked: 0` if no new emails matched.
|
||||
- **`404 Not Found`** — Hold with the given ID does not exist.
|
||||
- **`409 Conflict`** — The hold is inactive. Only active holds can receive new email links.
|
||||
- **`422 Unprocessable Entity`** — Invalid request body.
|
||||
|
||||
---
|
||||
|
||||
### Release All Emails from Hold
|
||||
|
||||
Removes all `email_legal_holds` associations for the given hold in a single operation. The hold itself is **not** deleted.
|
||||
|
||||
- **Endpoint:** `POST /holds/:id/release-all`
|
||||
- **Method:** `POST`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `manage:all`
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------ | -------------------------------- |
|
||||
| `id` | `uuid` | The UUID of the hold to release. |
|
||||
|
||||
#### Response Body
|
||||
|
||||
```json
|
||||
{
|
||||
"emailsReleased": 4821
|
||||
}
|
||||
```
|
||||
|
||||
#### Response Codes
|
||||
|
||||
- **`200 OK`** — All email associations removed. Returns `emailsReleased: 0` if the hold had no linked emails.
|
||||
- **`500 Internal Server Error`** — The hold ID was not found or a database error occurred.
|
||||
|
||||
> **Warning:** After release, emails that were solely protected by this hold will be evaluated normally on the next lifecycle worker cycle. Emails with expired retention periods will be deleted.
|
||||
|
||||
---
|
||||
|
||||
## Per-Email Hold Endpoints
|
||||
|
||||
### Get Holds Applied to an Email
|
||||
|
||||
Returns all legal holds currently linked to a specific archived email, including both active and inactive holds.
|
||||
|
||||
- **Endpoint:** `GET /email/:emailId/holds`
|
||||
- **Method:** `GET`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `read:archive`
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------ | ------------------------------- |
|
||||
| `emailId` | `uuid` | The UUID of the archived email. |
|
||||
|
||||
#### Response Body
|
||||
|
||||
Returns an empty array `[]` if no holds are applied, or an array of hold-link objects:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"legalHoldId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"holdName": "Project Titan Litigation — 2026",
|
||||
"isActive": true,
|
||||
"appliedAt": "2026-01-15T11:00:00.000Z",
|
||||
"appliedByUserId": "user-uuid-here"
|
||||
},
|
||||
{
|
||||
"legalHoldId": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
|
||||
"holdName": "SEC Investigation Q3 2025",
|
||||
"isActive": false,
|
||||
"appliedAt": "2025-09-05T09:15:00.000Z",
|
||||
"appliedByUserId": null
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### Response Codes
|
||||
|
||||
- **`200 OK`** — Returns the array of hold-link objects (may be empty).
|
||||
|
||||
---
|
||||
|
||||
### Apply a Hold to a Specific Email
|
||||
|
||||
Links a single archived email to an active legal hold. The operation is idempotent — linking the same email to the same hold twice has no effect.
|
||||
|
||||
- **Endpoint:** `POST /email/:emailId/holds`
|
||||
- **Method:** `POST`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `manage:all`
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------ | ------------------------------- |
|
||||
| `emailId` | `uuid` | The UUID of the archived email. |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| -------- | ------ | -------- | ------------------------------ |
|
||||
| `holdId` | `uuid` | Yes | The UUID of the hold to apply. |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```json
|
||||
{
|
||||
"holdId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||
}
|
||||
```
|
||||
|
||||
#### Response Body
|
||||
|
||||
Returns the hold-link object with the DB-authoritative `appliedAt` timestamp:
|
||||
|
||||
```json
|
||||
{
|
||||
"legalHoldId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"holdName": "Project Titan Litigation — 2026",
|
||||
"isActive": true,
|
||||
"appliedAt": "2026-01-16T14:22:00.000Z",
|
||||
"appliedByUserId": "user-uuid-here"
|
||||
}
|
||||
```
|
||||
|
||||
#### Response Codes
|
||||
|
||||
- **`200 OK`** — Hold successfully applied (or was already applied — idempotent).
|
||||
- **`404 Not Found`** — Email or hold not found.
|
||||
- **`409 Conflict`** — The hold is inactive and cannot be applied to new emails.
|
||||
- **`422 Unprocessable Entity`** — Invalid request body.
|
||||
|
||||
---
|
||||
|
||||
### Remove a Hold from a Specific Email
|
||||
|
||||
Unlinks a specific legal hold from a specific archived email. The hold itself is not modified; other emails linked to the same hold are unaffected.
|
||||
|
||||
- **Endpoint:** `DELETE /email/:emailId/holds/:holdId`
|
||||
- **Method:** `DELETE`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `manage:all`
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------ | ------------------------------- |
|
||||
| `emailId` | `uuid` | The UUID of the archived email. |
|
||||
| `holdId` | `uuid` | The UUID of the hold to remove. |
|
||||
|
||||
#### Response Body
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Hold removed from email successfully."
|
||||
}
|
||||
```
|
||||
|
||||
#### Response Codes
|
||||
|
||||
- **`200 OK`** — Hold link removed.
|
||||
- **`404 Not Found`** — No such hold was applied to this email.
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints use the standard error response format:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"statusCode": 409,
|
||||
"message": "Cannot delete an active legal hold. Deactivate it first to explicitly lift legal protection before deletion.",
|
||||
"errors": null
|
||||
}
|
||||
```
|
||||
|
||||
For validation errors (`422 Unprocessable Entity`):
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"statusCode": 422,
|
||||
"message": "Invalid input provided.",
|
||||
"errors": [
|
||||
{
|
||||
"field": "name",
|
||||
"message": "Name is required."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validation Constraints
|
||||
|
||||
| Field | Constraint |
|
||||
| ------------------ | ---------------------------------------- |
|
||||
| Hold name | 1–255 characters. |
|
||||
| Reason | Max 2 000 characters. |
|
||||
| `caseId` | Must be a valid UUID if provided. |
|
||||
| `holdId` | Must be a valid UUID. |
|
||||
| `emailId` | Must be a valid UUID. |
|
||||
| Search `query` | String (may be empty `""`). |
|
||||
| `matchingStrategy` | One of `"last"`, `"all"`, `"frequency"`. |
|
||||
164
docs/enterprise/legal-holds/guide.md
Normal file
164
docs/enterprise/legal-holds/guide.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Legal Holds: User Interface Guide
|
||||
|
||||
The legal holds management interface is located at **Dashboard → Compliance → Legal Holds**. It provides a complete view of all configured holds and tools for creating, applying, releasing, and deactivating them. Per-email hold controls are also available on each archived email's detail page.
|
||||
|
||||
## Overview
|
||||
|
||||
Legal holds suspend all automated and manual deletion for specific emails, regardless of any retention labels or policies that might otherwise govern them. They are the highest-priority mechanism in the data lifecycle and are intended for use by compliance officers and legal counsel responding to litigation, investigations, or audit requests.
|
||||
|
||||
## Holds Table
|
||||
|
||||
The main page displays a table of all legal holds with the following columns:
|
||||
|
||||
- **Name:** The hold name and its UUID displayed underneath for reference.
|
||||
- **Reason:** A short excerpt of the hold's reason/description. Shows _"No reason provided"_ if omitted.
|
||||
- **Emails:** A badge showing how many archived emails are currently linked to this hold.
|
||||
- **Status:** A badge indicating whether the hold is:
|
||||
- **Active** (red badge): The hold is currently granting deletion immunity to linked emails.
|
||||
- **Inactive** (gray badge): The hold is deactivated; linked emails are no longer immune.
|
||||
- **Created At:** The date the hold was created, in local date format.
|
||||
- **Actions:** Dropdown menu with options depending on the hold's state (see below).
|
||||
|
||||
The table is sorted by creation date in ascending order.
|
||||
|
||||
## Creating a Hold
|
||||
|
||||
Click the **"Create New"** button above the table to open the creation dialog. New holds are always created in the **Active** state.
|
||||
|
||||
### Form Fields
|
||||
|
||||
- **Name** (Required): A unique, descriptive name. Maximum 255 characters.
|
||||
Examples: `"Project Titan Litigation — 2026"`, `"SEC Investigation Q3 2025"`
|
||||
- **Reason** (Optional): A free-text description of the legal basis for the hold. Maximum 2 000 characters. This appears in the audit log and is visible to other compliance officers.
|
||||
|
||||
### After Creation
|
||||
|
||||
The hold immediately becomes active. No emails are linked to it yet — use Bulk Apply or the individual email detail page to add emails.
|
||||
|
||||
## Editing a Hold
|
||||
|
||||
Click **Edit** from the actions dropdown to modify the hold's name or reason. The `isActive` state is changed separately via the **Activate / Deactivate** action.
|
||||
|
||||
## Activating and Deactivating a Hold
|
||||
|
||||
The **Deactivate** / **Activate** option appears inline in the actions dropdown. Changing the active state does not remove any email links — it only determines whether those links grant deletion immunity.
|
||||
|
||||
> **Important:** Deactivating a hold means that all emails linked _solely_ to this hold lose their deletion immunity immediately. If any such emails have an expired retention period, they will be permanently deleted on the very next lifecycle worker cycle.
|
||||
|
||||
## Deleting a Hold
|
||||
|
||||
A hold **cannot be deleted while it is active**. Attempting to delete an active hold returns a `409 Conflict` error with the message: _"Cannot delete an active legal hold. Deactivate it first..."_
|
||||
|
||||
To delete a hold:
|
||||
|
||||
1. **Deactivate** it first using the Activate/Deactivate action.
|
||||
2. Click **Delete** from the actions dropdown.
|
||||
3. Confirm in the dialog.
|
||||
|
||||
Deletion permanently removes the hold record and, via database CASCADE, all `email_legal_holds` link rows. The emails themselves are not deleted — they simply lose the protection that this hold was providing. Any other active holds on those emails continue to protect them.
|
||||
|
||||
## Bulk Apply
|
||||
|
||||
The **Bulk Apply** option (available only on active holds) opens a search dialog that lets you cast a preservation net across potentially thousands of emails in a single operation.
|
||||
|
||||
### Search Fields
|
||||
|
||||
- **Full-text query:** Keywords to match against email subject, body, and attachment content. This uses Meilisearch's full-text engine with typo tolerance.
|
||||
- **From (sender):** Filter by sender email address.
|
||||
- **Start date / End date:** Filter by the date range of the email's `sentAt` field.
|
||||
|
||||
At least one of these fields must be filled before the **Apply Hold** button becomes enabled.
|
||||
|
||||
### What Happens During Bulk Apply
|
||||
|
||||
1. The system pages through all Meilisearch results matching the query (1 000 hits per page).
|
||||
2. Each hit's email ID is validated against the database to discard any stale index entries.
|
||||
3. New hold links are inserted in batches of 500. Emails already linked to this hold are skipped (idempotent).
|
||||
4. A success notification shows **how many emails were newly placed under the hold** (already-protected emails are not counted again).
|
||||
5. The exact search query JSON is written to the audit log as GoBD proof of the scope of protection.
|
||||
|
||||
> **Warning:** Bulk Apply is a wide-net operation. Review your query carefully — there is no per-email confirmation step. Use the search page first to preview results before applying.
|
||||
|
||||
### Bulk Apply and the Audit Log
|
||||
|
||||
The audit log entry for a bulk apply contains:
|
||||
|
||||
- `action: "BulkApplyHold"`
|
||||
- `searchQuery`: the exact JSON query used
|
||||
- `emailsLinked`: number of emails newly linked
|
||||
- `emailsAlreadyProtected`: number of emails that were already under this hold
|
||||
|
||||
## Release All Emails
|
||||
|
||||
The **Release All** option (available when the hold has at least one linked email) removes every `email_legal_holds` link for this hold in a single operation.
|
||||
|
||||
> **Warning:** This immediately lifts deletion immunity for all emails that were solely protected by this hold. Emails with expired retention periods will be deleted on the next lifecycle worker cycle.
|
||||
|
||||
A confirmation dialog is shown before the operation proceeds. On success, a notification reports how many email links were removed.
|
||||
|
||||
## Per-Email Hold Controls
|
||||
|
||||
### Viewing Holds on a Specific Email
|
||||
|
||||
On any archived email's detail page, the **Legal Holds** card lists all holds currently applied to that email, showing:
|
||||
|
||||
- Hold name and active/inactive badge
|
||||
- Date the hold was applied
|
||||
|
||||
### Applying a Hold to a Specific Email
|
||||
|
||||
In the Legal Holds card, a dropdown lists all currently **active** holds. Select a hold and click **Apply**. The operation is idempotent — applying the same hold twice has no effect.
|
||||
|
||||
### Removing a Hold from a Specific Email
|
||||
|
||||
Each linked hold in the card has a **Remove** button. Clicking it removes only the link between this email and that specific hold. The hold itself remains and continues to protect other emails.
|
||||
|
||||
> **Note:** Removing the last active hold from an email means the email is no longer immune. If its retention period has expired, it will be deleted on the next lifecycle worker cycle.
|
||||
|
||||
### Delete Button Behaviour Under a Hold
|
||||
|
||||
The **Delete Email** button on the email detail page is not disabled in the UI, but the backend will reject the request if the email is under an active hold. An error toast is displayed: _"Deletion blocked by retention policy (Legal Hold or similar)."_
|
||||
|
||||
## Permissions Reference
|
||||
|
||||
| Operation | Required Permission |
|
||||
| -------------------------------- | ------------------- |
|
||||
| View holds table | `manage:all` |
|
||||
| Create / edit / delete a hold | `manage:all` |
|
||||
| Activate / deactivate a hold | `manage:all` |
|
||||
| Bulk apply | `manage:all` |
|
||||
| Release all emails from a hold | `manage:all` |
|
||||
| View holds on a specific email | `read:archive` |
|
||||
| Apply / remove a hold from email | `manage:all` |
|
||||
|
||||
## Workflow: Responding to a Litigation Notice
|
||||
|
||||
1. **Receive the litigation notice.** Identify the relevant custodians, date range, and keywords.
|
||||
2. **Create a hold**: Navigate to Dashboard → Compliance → Legal Holds and click **Create New**. Name it descriptively (e.g., `"Doe v. Acme Corp — 2026"`). Add the legal matter reference as the reason.
|
||||
3. **Bulk apply**: Click **Bulk Apply** on the new hold. Enter keywords, the custodian's email address in the **From** field, and the relevant date range. Submit.
|
||||
4. **Verify**: Check the email count badge on the hold row. Review the audit log to confirm the search query was recorded.
|
||||
5. **Individual additions**: If specific emails not captured by the bulk query need to be preserved, open each email's detail page and apply the hold manually.
|
||||
6. **When the matter concludes**: Click **Deactivate** on the hold, then **Release All** to remove all email links, and finally **Delete** the hold record if desired.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Cannot Delete Hold — "Cannot delete an active legal hold"
|
||||
|
||||
**Cause:** The hold is still active.
|
||||
**Solution:** Use the **Deactivate** option from the actions dropdown first.
|
||||
|
||||
### Bulk Apply Returns 0 Emails
|
||||
|
||||
**Cause 1:** The search query matched no documents in the Meilisearch index.
|
||||
**Solution:** Verify the query in the main Search page to preview results before applying.
|
||||
**Cause 2:** All Meilisearch results were stale (emails deleted from the archive before this operation).
|
||||
**Solution:** This is a data state issue; the stale index entries will be cleaned up on the next index rebuild.
|
||||
|
||||
### Delete Email Returns an Error Instead of Deleting
|
||||
|
||||
**Cause:** The email is under one or more active legal holds.
|
||||
**Solution:** This is expected behavior. Deactivate or remove the hold(s) from this email before deleting.
|
||||
|
||||
### Hold Emails Count Shows 0 After Bulk Apply
|
||||
|
||||
**Cause:** The `emailCount` field is fetched when the page loads. If the bulk operation was just completed, refresh the page to see the updated count.
|
||||
125
docs/enterprise/legal-holds/index.md
Normal file
125
docs/enterprise/legal-holds/index.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Legal Holds
|
||||
|
||||
The Legal Holds feature is an enterprise-grade eDiscovery and compliance mechanism designed to prevent the spoliation (destruction) of evidence. It provides **absolute, unconditional immunity** from deletion for archived emails that are relevant to pending litigation, regulatory investigations, or audits.
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. Absolute Immunity — Highest Precedence in the Lifecycle Pipeline
|
||||
|
||||
A legal hold is the final word on whether an email can be deleted. The [Lifecycle Worker](../retention-policy/lifecycle-worker.md) evaluates emails in a strict three-step precedence pipeline:
|
||||
|
||||
1. **Step 0 — Legal Hold** ← this feature
|
||||
2. Step 1 — Retention Label
|
||||
3. Step 2 — Retention Policy
|
||||
|
||||
If an email is linked to **at least one active** legal hold, the lifecycle worker immediately flags it as immune and stops evaluation. No retention label or policy can override this decision. The `RetentionHook` mechanism also blocks any **manual deletion** attempt from the UI — the backend will return an error before any `DELETE` SQL is issued.
|
||||
|
||||
### 2. Many-to-Many Relationship
|
||||
|
||||
A single email can be placed under multiple holds simultaneously (e.g., one hold for a litigation case and another for a regulatory investigation). The email remains immune as long as **any one** of those holds is active. Each hold-to-email link is recorded independently with its own `appliedAt` timestamp and actor attribution.
|
||||
|
||||
### 3. Active/Inactive State Management
|
||||
|
||||
Every hold has an `isActive` flag. When a legal matter concludes, the responsible officer deactivates the hold. The deactivation is instantaneous — on the very next lifecycle worker cycle, emails that were solely protected by that hold will be evaluated normally against retention labels and policies. If their retention period has already expired, they will be permanently deleted in that same cycle.
|
||||
|
||||
A hold **must be deactivated before it can be deleted**. This requirement forces an explicit, auditable act of lifting legal protection before the hold record can be removed from the system.
|
||||
|
||||
### 4. Bulk Preservation via Search Queries
|
||||
|
||||
The primary use case for legal holds is casting a wide preservation net quickly. The bulk-apply operation accepts a full Meilisearch query (full-text search + metadata filters such as sender, date range, etc.) and links every matching email to the hold in a single operation. The system pages through results in batches of 1 000 to handle datasets of any size without timing out the UI.
|
||||
|
||||
### 5. GoBD Audit Trail
|
||||
|
||||
Every action within the legal hold module — hold creation, modification, deactivation, deletion, email linkage, email removal, and bulk operations — is immutably recorded in the cryptographically chained `audit_logs` table. For bulk operations, the exact `SearchQuery` JSON used to cast the hold net is persisted in the audit log as proof of scope, satisfying GoBD and similar evidence-preservation requirements.
|
||||
|
||||
## Feature Requirements
|
||||
|
||||
The Legal Holds feature requires:
|
||||
|
||||
- An active **Enterprise license** with the `LEGAL_HOLDS` feature enabled.
|
||||
- The `manage:all` permission for all hold management and bulk operations.
|
||||
- The `read:archive` permission for viewing holds applied to a specific email.
|
||||
- The `manage:all` permission for applying or removing a hold from an individual email.
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Active Litigation Hold
|
||||
|
||||
Upon receiving a litigation notice, a compliance officer creates a hold named "Project Titan Litigation — 2026", applies it via a bulk query scoped to a specific custodian's emails and a date range, and immediately freezes those records. The audit log provides timestamped proof that the hold was in place from the moment of creation.
|
||||
|
||||
### Regulatory Investigation
|
||||
|
||||
A regulator requests preservation of all finance-related communications from a specific period. The officer creates a hold and uses a keyword + date-range bulk query to capture every relevant email in seconds, regardless of which users sent or received them.
|
||||
|
||||
### Tax Audit
|
||||
|
||||
Before an annual audit window, an officer applies a hold to all emails matching tax-relevant keywords. The hold is released once the audit concludes, and standard retention policies resume.
|
||||
|
||||
### eDiscovery Case Management
|
||||
|
||||
Holds can optionally be linked to an `ediscovery_cases` record (`caseId` field) to organise multiple holds under a single legal matter. This allows all holds, emails, and audit events for a case to be referenced together.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
| Component | Location | Description |
|
||||
| --------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| Types | `packages/types/src/retention.types.ts` | `LegalHold`, `EmailLegalHoldInfo`, `BulkApplyHoldResult` types |
|
||||
| Database Schema | `packages/backend/src/database/schema/compliance.ts` | `legal_holds` and `email_legal_holds` table definitions |
|
||||
| Service | `packages/enterprise/src/modules/legal-holds/LegalHoldService.ts` | All business logic for CRUD, linkage, and bulk operations |
|
||||
| Controller | `packages/enterprise/src/modules/legal-holds/legal-hold.controller.ts` | Express request handlers with Zod validation |
|
||||
| Routes | `packages/enterprise/src/modules/legal-holds/legal-hold.routes.ts` | Route registration with auth and feature guards |
|
||||
| Module | `packages/enterprise/src/modules/legal-holds/legal-hold.module.ts` | App-startup integration and `RetentionHook` registration |
|
||||
| Frontend Page | `packages/frontend/src/routes/dashboard/compliance/legal-holds/` | SvelteKit management page for holds |
|
||||
| Email Detail | `packages/frontend/src/routes/dashboard/archived-emails/[id]/` | Per-email hold card in the email detail view |
|
||||
| Lifecycle Guard | `packages/backend/src/hooks/RetentionHook.ts` | Static hook that blocks deletion if a hold is active |
|
||||
|
||||
## Data Model
|
||||
|
||||
### `legal_holds` Table
|
||||
|
||||
| Column | Type | Description |
|
||||
| ------------ | -------------- | --------------------------------------------------------------------------- |
|
||||
| `id` | `uuid` (PK) | Auto-generated unique identifier. |
|
||||
| `name` | `varchar(255)` | Human-readable hold name. |
|
||||
| `reason` | `text` | Optional description of why the hold was placed. |
|
||||
| `is_active` | `boolean` | Whether the hold currently grants immunity. Defaults to `true` on creation. |
|
||||
| `case_id` | `uuid` (FK) | Optional reference to an `ediscovery_cases` row. |
|
||||
| `created_at` | `timestamptz` | Hold creation timestamp. |
|
||||
| `updated_at` | `timestamptz` | Last modification timestamp. |
|
||||
|
||||
### `email_legal_holds` Join Table
|
||||
|
||||
| Column | Type | Description |
|
||||
| -------------------- | ------------- | ----------------------------------------------------------- |
|
||||
| `email_id` | `uuid` (FK) | Reference to `archived_emails.id`. Cascades on delete. |
|
||||
| `legal_hold_id` | `uuid` (FK) | Reference to `legal_holds.id`. Cascades on delete. |
|
||||
| `applied_at` | `timestamptz` | DB-server timestamp of when the link was created. |
|
||||
| `applied_by_user_id` | `uuid` (FK) | User who applied the hold (nullable for system operations). |
|
||||
|
||||
The table uses a composite primary key of `(email_id, legal_hold_id)`, enforcing uniqueness at the database level. Duplicate inserts use `ON CONFLICT DO NOTHING` for idempotency.
|
||||
|
||||
## Integration Points
|
||||
|
||||
### RetentionHook (Deletion Guard)
|
||||
|
||||
`LegalHoldModule.initialize()` registers an async check with `RetentionHook` at application startup. `ArchivedEmailService.deleteArchivedEmail()` calls `RetentionHook.canDelete(emailId)` before any storage or database DELETE. If the email is under an active hold, the hook returns `false` and deletion is aborted with a `400 Bad Request` error. This guard is fail-safe: if the hook itself throws an error, deletion is also blocked.
|
||||
|
||||
### Lifecycle Worker
|
||||
|
||||
The lifecycle worker calls `legalHoldService.isEmailUnderActiveHold(emailId)` as the first step in its per-email evaluation loop. Immune emails are skipped immediately with a `debug`-level log entry; no further evaluation occurs.
|
||||
|
||||
### Audit Log
|
||||
|
||||
All legal hold operations generate entries in `audit_logs`:
|
||||
|
||||
| Action | `actionType` | `targetType` | `targetId` |
|
||||
| --------------------------------- | ------------ | --------------- | -------------------- |
|
||||
| Hold created | `CREATE` | `LegalHold` | hold ID |
|
||||
| Hold updated / deactivated | `UPDATE` | `LegalHold` | hold ID |
|
||||
| Hold deleted | `DELETE` | `LegalHold` | hold ID |
|
||||
| Email linked to hold (individual) | `UPDATE` | `ArchivedEmail` | email ID |
|
||||
| Email unlinked from hold | `UPDATE` | `ArchivedEmail` | email ID |
|
||||
| Bulk apply via search | `UPDATE` | `LegalHold` | hold ID + query JSON |
|
||||
| All emails released from hold | `UPDATE` | `LegalHold` | hold ID |
|
||||
|
||||
Individual email link/unlink events target `ArchivedEmail` so that a per-email audit search surfaces the complete hold history for that email.
|
||||
360
docs/enterprise/retention-labels/api.md
Normal file
360
docs/enterprise/retention-labels/api.md
Normal file
@@ -0,0 +1,360 @@
|
||||
# Retention Labels: API Endpoints
|
||||
|
||||
The retention labels feature exposes a RESTful API for managing retention labels and applying them to individual archived emails. All endpoints require authentication and appropriate permissions as specified below.
|
||||
|
||||
**Base URL:** `/api/v1/enterprise/retention-policy`
|
||||
|
||||
All endpoints also require the `RETENTION_POLICY` feature to be enabled in the enterprise license.
|
||||
|
||||
---
|
||||
|
||||
## Label Management Endpoints
|
||||
|
||||
### List All Labels
|
||||
|
||||
Retrieves all retention labels, ordered by creation date ascending.
|
||||
|
||||
- **Endpoint:** `GET /labels`
|
||||
- **Method:** `GET`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `manage:all`
|
||||
|
||||
#### Response Body
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"name": "Legal Hold - Litigation ABC",
|
||||
"description": "Extended retention for emails related to litigation ABC vs Company",
|
||||
"retentionPeriodDays": 2555,
|
||||
"isDisabled": false,
|
||||
"createdAt": "2025-10-01T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
|
||||
"name": "Executive Communications",
|
||||
"description": null,
|
||||
"retentionPeriodDays": 3650,
|
||||
"isDisabled": true,
|
||||
"createdAt": "2025-09-15T12:30:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Label by ID
|
||||
|
||||
Retrieves a single retention label by its UUID.
|
||||
|
||||
- **Endpoint:** `GET /labels/:id`
|
||||
- **Method:** `GET`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `manage:all`
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------ | ----------------------------- |
|
||||
| `id` | `uuid` | The UUID of the label to get. |
|
||||
|
||||
#### Response Body
|
||||
|
||||
Returns a single label object (same shape as the list endpoint), or `404` if not found.
|
||||
|
||||
---
|
||||
|
||||
### Create Label
|
||||
|
||||
Creates a new retention label. The label name must be unique across the system.
|
||||
|
||||
- **Endpoint:** `POST /labels`
|
||||
- **Method:** `POST`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `manage:all`
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --------------------- | --------- | -------- | ----------------------------------------------------------- |
|
||||
| `name` | `string` | Yes | Unique label name. Max 255 characters. |
|
||||
| `description` | `string` | No | Human-readable description. Max 1000 characters. |
|
||||
| `retentionPeriodDays` | `integer` | Yes | Number of days to retain emails with this label. Minimum 1. |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Financial Records - Q4 2025",
|
||||
"description": "Extended retention for Q4 2025 financial correspondence per regulatory requirements",
|
||||
"retentionPeriodDays": 2555
|
||||
}
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
- **`201 Created`** — Returns the created label object.
|
||||
- **`409 Conflict`** — A label with this name already exists.
|
||||
- **`422 Unprocessable Entity`** — Validation errors.
|
||||
|
||||
---
|
||||
|
||||
### Update Label
|
||||
|
||||
Updates an existing retention label. Only the fields included in the request body are modified.
|
||||
|
||||
- **Endpoint:** `PUT /labels/:id`
|
||||
- **Method:** `PUT`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `manage:all`
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------ | -------------------------------- |
|
||||
| `id` | `uuid` | The UUID of the label to update. |
|
||||
|
||||
#### Request Body
|
||||
|
||||
All fields from the create endpoint are accepted, and all are optional. Only provided fields are updated.
|
||||
|
||||
**Important:** The `retentionPeriodDays` field cannot be modified if the label is currently applied to any emails. Attempting to do so will return a `409 Conflict` error.
|
||||
|
||||
#### Example Request
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Financial Records - Q4 2025 (Updated)",
|
||||
"description": "Updated description for Q4 2025 financial records retention"
|
||||
}
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
- **`200 OK`** — Returns the updated label object.
|
||||
- **`404 Not Found`** — Label with the given ID does not exist.
|
||||
- **`409 Conflict`** — Attempted to modify retention period while label is applied to emails.
|
||||
- **`422 Unprocessable Entity`** — Validation errors.
|
||||
|
||||
---
|
||||
|
||||
### Delete Label
|
||||
|
||||
Deletes or disables a retention label depending on its usage status.
|
||||
|
||||
- **Endpoint:** `DELETE /labels/:id`
|
||||
- **Method:** `DELETE`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `manage:all`
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------ | -------------------------------- |
|
||||
| `id` | `uuid` | The UUID of the label to delete. |
|
||||
|
||||
#### Deletion Logic
|
||||
|
||||
- **Hard Delete**: If the label has never been applied to any emails, it is permanently removed.
|
||||
- **Soft Disable**: If the label is currently applied to one or more emails, it is marked as `isDisabled = true` instead of being deleted. This preserves the retention clock for tagged emails while preventing new applications.
|
||||
|
||||
#### Response Body
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "deleted"
|
||||
}
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "disabled"
|
||||
}
|
||||
```
|
||||
|
||||
#### Response Codes
|
||||
|
||||
- **`200 OK`** — Label successfully deleted or disabled. Check the `action` field in the response body.
|
||||
- **`404 Not Found`** — Label with the given ID does not exist.
|
||||
|
||||
---
|
||||
|
||||
## Email Label Endpoints
|
||||
|
||||
### Get Email's Label
|
||||
|
||||
Retrieves the retention label currently applied to a specific archived email.
|
||||
|
||||
- **Endpoint:** `GET /email/:emailId/label`
|
||||
- **Method:** `GET`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `read:archive`
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------ | ------------------------------- |
|
||||
| `emailId` | `uuid` | The UUID of the archived email. |
|
||||
|
||||
#### Response Body
|
||||
|
||||
Returns `null` if no label is applied:
|
||||
|
||||
```json
|
||||
null
|
||||
```
|
||||
|
||||
Or the label information if a label is applied:
|
||||
|
||||
```json
|
||||
{
|
||||
"labelId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"labelName": "Legal Hold - Litigation ABC",
|
||||
"retentionPeriodDays": 2555,
|
||||
"appliedAt": "2025-10-15T14:30:00.000Z",
|
||||
"appliedByUserId": "user123"
|
||||
}
|
||||
```
|
||||
|
||||
#### Response Codes
|
||||
|
||||
- **`200 OK`** — Returns label information or `null`.
|
||||
- **`500 Internal Server Error`** — Server error during processing.
|
||||
|
||||
---
|
||||
|
||||
### Apply Label to Email
|
||||
|
||||
Applies a retention label to an archived email. If the email already has a label, the existing label is replaced.
|
||||
|
||||
- **Endpoint:** `POST /email/:emailId/label`
|
||||
- **Method:** `POST`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `delete:archive`
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------ | ------------------------------- |
|
||||
| `emailId` | `uuid` | The UUID of the archived email. |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --------- | ------ | -------- | ------------------------------- |
|
||||
| `labelId` | `uuid` | Yes | The UUID of the label to apply. |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```json
|
||||
{
|
||||
"labelId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||
}
|
||||
```
|
||||
|
||||
#### Response Body
|
||||
|
||||
```json
|
||||
{
|
||||
"labelId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"labelName": "Legal Hold - Litigation ABC",
|
||||
"retentionPeriodDays": 2555,
|
||||
"appliedAt": "2025-10-15T14:30:00.000Z",
|
||||
"appliedByUserId": "user123"
|
||||
}
|
||||
```
|
||||
|
||||
#### Response Codes
|
||||
|
||||
- **`200 OK`** — Label successfully applied.
|
||||
- **`404 Not Found`** — Email or label not found.
|
||||
- **`409 Conflict`** — Attempted to apply a disabled label.
|
||||
- **`422 Unprocessable Entity`** — Invalid request body.
|
||||
|
||||
---
|
||||
|
||||
### Remove Label from Email
|
||||
|
||||
Removes the retention label from an archived email if one is applied.
|
||||
|
||||
- **Endpoint:** `DELETE /email/:emailId/label`
|
||||
- **Method:** `DELETE`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `delete:archive`
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------ | ------------------------------- |
|
||||
| `emailId` | `uuid` | The UUID of the archived email. |
|
||||
|
||||
#### Response Body
|
||||
|
||||
If a label was removed:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Label removed successfully."
|
||||
}
|
||||
```
|
||||
|
||||
If no label was applied:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "No label was applied to this email."
|
||||
}
|
||||
```
|
||||
|
||||
#### Response Codes
|
||||
|
||||
- **`200 OK`** — Operation completed (regardless of whether a label was actually removed).
|
||||
- **`500 Internal Server Error`** — Server error during processing.
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints use the standard error response format:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"statusCode": 404,
|
||||
"message": "The requested resource could not be found.",
|
||||
"errors": null
|
||||
}
|
||||
```
|
||||
|
||||
For validation errors (`422 Unprocessable Entity`):
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"statusCode": 422,
|
||||
"message": "Invalid input provided.",
|
||||
"errors": [
|
||||
{
|
||||
"field": "name",
|
||||
"message": "Name is required."
|
||||
},
|
||||
{
|
||||
"field": "retentionPeriodDays",
|
||||
"message": "Retention period must be at least 1 day."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Validation Constraints
|
||||
|
||||
| Field | Constraint |
|
||||
| ---------------- | --------------------------------- |
|
||||
| Label name | 1–255 characters, must be unique. |
|
||||
| Description | Max 1000 characters. |
|
||||
| Retention period | Positive integer (≥ 1 day). |
|
||||
| Label ID (UUID) | Must be a valid UUID format. |
|
||||
| Email ID (UUID) | Must be a valid UUID format. |
|
||||
267
docs/enterprise/retention-labels/automated-tagging.md
Normal file
267
docs/enterprise/retention-labels/automated-tagging.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# Retention Labels: Automated Application Guide
|
||||
|
||||
This guide explains how to use the API to automatically apply retention labels to archived emails, enabling automated compliance and retention management workflows.
|
||||
|
||||
## Overview
|
||||
|
||||
Automated retention label application allows external systems and services to programmatically tag emails with appropriate retention labels based on content analysis, business rules, or regulatory requirements. This eliminates manual tagging for large volumes of emails while ensuring consistent retention policy enforcement.
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### 1. Financial Document Classification
|
||||
|
||||
**Scenario**: Automatically identify and tag financial documents (invoices, receipts, payment confirmations) with extended retention periods for regulatory compliance.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
- Monitor newly ingested emails for financial keywords in subject lines or attachment names
|
||||
- Apply "Financial Records" label (typically 7+ years retention) to matching emails
|
||||
- Use content analysis to identify financial document types
|
||||
|
||||
### 2. Legal and Compliance Tagging
|
||||
|
||||
**Scenario**: Apply legal hold labels to emails related to ongoing litigation or regulatory investigations.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
- Scan emails for legal-related keywords or specific case references
|
||||
- Tag emails from/to legal departments with "Legal Hold" labels
|
||||
- Apply extended retention periods to preserve evidence
|
||||
|
||||
### 3. Executive Communication Preservation
|
||||
|
||||
**Scenario**: Ensure important communications involving executive leadership are retained beyond standard policies.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
- Identify emails from C-level executives (CEO, CFO, CTO, etc.)
|
||||
- Apply "Executive Communications" labels with extended retention
|
||||
- Preserve strategic business communications for historical reference
|
||||
|
||||
### 4. Data Classification Integration
|
||||
|
||||
**Scenario**: Integrate with existing data classification systems to apply retention labels based on content sensitivity.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
- Use AI/ML classification results to determine retention requirements
|
||||
- Apply labels like "Confidential", "Public", or "Restricted" with appropriate retention periods
|
||||
- Automate compliance with data protection regulations
|
||||
|
||||
### 5. Project-Based Retention
|
||||
|
||||
**Scenario**: Apply specific retention periods to emails related to particular projects or contracts.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
- Identify project-related emails using subject line patterns or participant lists
|
||||
- Tag with project-specific labels (e.g., "Project Alpha - 5 Year Retention")
|
||||
- Ensure project documentation meets contractual retention requirements
|
||||
|
||||
## API Workflow
|
||||
|
||||
### Step 1: Authentication Setup
|
||||
|
||||
Create an API key with appropriate permissions:
|
||||
|
||||
- Navigate to **Dashboard → Admin → Roles/Users**
|
||||
- Create a user with `read:archive` and `delete:archive` permissions (minimum required)
|
||||
- Generate an API for the newly created user
|
||||
- Securely store the API key for use in automated systems
|
||||
|
||||
### Step 2: Identify Target Emails
|
||||
|
||||
Use the archived emails API to find emails that need labeling:
|
||||
|
||||
**Get Recent Emails**:
|
||||
|
||||
```
|
||||
GET /api/v1/archived-emails?limit=100&sort=archivedAt:desc
|
||||
```
|
||||
|
||||
**Search for Specific Emails**:
|
||||
|
||||
```
|
||||
GET /api/v1/archived-emails/search?query=subject:invoice&limit=50
|
||||
```
|
||||
|
||||
### Step 3: Check Current Label Status
|
||||
|
||||
Before applying a new label, verify the email's current state:
|
||||
|
||||
**Check Email Label**:
|
||||
|
||||
```
|
||||
GET /api/v1/enterprise/retention-policy/email/{emailId}/label
|
||||
```
|
||||
|
||||
This returns `null` if no label is applied, or the current label information if one exists.
|
||||
|
||||
### Step 4: Apply Retention Label
|
||||
|
||||
Apply the appropriate label to the email:
|
||||
|
||||
**Apply Label**:
|
||||
|
||||
```
|
||||
POST /api/v1/enterprise/retention-policy/email/{emailId}/label
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"labelId": "your-label-uuid-here"
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Verify Application
|
||||
|
||||
Confirm the label was successfully applied by checking the response or making another GET request.
|
||||
|
||||
## Label Management
|
||||
|
||||
### Getting Available Labels
|
||||
|
||||
List all available retention labels to identify which ones to use:
|
||||
|
||||
```
|
||||
GET /api/v1/enterprise/retention-policy/labels
|
||||
```
|
||||
|
||||
This returns all labels with their IDs, names, retention periods, and status (enabled/disabled).
|
||||
|
||||
### Label Selection Strategy
|
||||
|
||||
- **Pre-create labels** through the UI with appropriate names and retention periods
|
||||
- **Map business rules** to specific label IDs in your automation logic
|
||||
- **Cache label information** to avoid repeated API calls
|
||||
- **Handle disabled labels** gracefully (they cannot be applied to new emails)
|
||||
|
||||
## Implementation Patterns
|
||||
|
||||
### Pattern 1: Post-Ingestion Processing
|
||||
|
||||
Apply labels after emails have been fully ingested and indexed:
|
||||
|
||||
1. Monitor for newly ingested emails (via webhooks or polling)
|
||||
2. Analyze email content and metadata
|
||||
3. Determine appropriate retention label based on business rules
|
||||
4. Apply the label via API
|
||||
|
||||
### Pattern 2: Batch Processing
|
||||
|
||||
Process emails in scheduled batches:
|
||||
|
||||
1. Query for unlabeled emails periodically (daily/weekly)
|
||||
2. Process emails in manageable batches (50-100 emails)
|
||||
3. Apply classification logic and labels
|
||||
4. Log results for audit and monitoring
|
||||
|
||||
### Pattern 3: Event-Driven Tagging
|
||||
|
||||
React to specific events or triggers:
|
||||
|
||||
1. Receive notification of specific events (legal hold notice, project start, etc.)
|
||||
2. Search for relevant emails based on criteria
|
||||
3. Apply appropriate labels to all matching emails
|
||||
4. Document the mass labeling action
|
||||
|
||||
## Authentication and Security
|
||||
|
||||
### API Key Management
|
||||
|
||||
- **Use dedicated API keys** for automated systems (not user accounts)
|
||||
- **Assign minimal required permissions** (`delete:archive` for label application)
|
||||
- **Rotate API keys regularly** as part of security best practices
|
||||
- **Store keys securely** using environment variables or secret management systems
|
||||
|
||||
### Request Authentication
|
||||
|
||||
Include the API key in all requests:
|
||||
|
||||
```
|
||||
Authorization: Bearer your-api-key-here
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Error Scenarios
|
||||
|
||||
- **404 Email Not Found**: The specified email ID doesn't exist
|
||||
- **404 Label Not Found**: The label ID is invalid or label has been deleted
|
||||
- **409 Conflict**: Attempting to apply a disabled label
|
||||
- **422 Validation Error**: Invalid request format or missing required fields
|
||||
|
||||
### Best Practices
|
||||
|
||||
- **Check response status codes** and handle errors appropriately
|
||||
- **Implement retry logic** for temporary failures (5xx errors)
|
||||
- **Log all operations** for audit trails and debugging
|
||||
- **Continue processing** other emails even if some fail
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
- **Process emails in batches** rather than individually when possible
|
||||
- **Add delays between API calls** to avoid overwhelming the server
|
||||
- **Monitor API response times** and adjust batch sizes accordingly
|
||||
|
||||
### Efficiency Tips
|
||||
|
||||
- **Cache label information** to reduce API calls
|
||||
- **Check existing labels** before applying new ones to avoid unnecessary operations
|
||||
- **Use search API** to filter emails rather than processing all emails
|
||||
- **Implement incremental processing** to handle only new or modified emails
|
||||
|
||||
## Monitoring and Auditing
|
||||
|
||||
### Logging Recommendations
|
||||
|
||||
- **Log all label applications** with email ID, label ID, and timestamp
|
||||
- **Track success/failure rates** for monitoring system health
|
||||
- **Record business rule matches** for compliance reporting
|
||||
|
||||
### Audit Trail
|
||||
|
||||
All automated label applications are recorded in the system audit log with:
|
||||
|
||||
- Actor identified as the API key name
|
||||
- Target email and applied label details
|
||||
- Timestamp of the operation
|
||||
|
||||
This ensures full traceability of automated retention decisions.
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### Scenario: Invoice Processing System
|
||||
|
||||
1. **Trigger**: New email arrives with invoice attachment
|
||||
2. **Analysis**: System identifies invoice keywords or attachment types
|
||||
3. **Action**: Apply "Financial Records - 7 Year" label via API
|
||||
4. **Result**: Email retained for regulatory compliance period
|
||||
|
||||
### Scenario: Legal Hold Implementation
|
||||
|
||||
1. **Trigger**: Legal department issues hold notice for specific matter
|
||||
2. **Search**: Find all emails matching case criteria (participants, keywords, date range)
|
||||
3. **Action**: Apply "Legal Hold - Matter XYZ" label to all matching emails
|
||||
4. **Result**: All relevant emails preserved indefinitely
|
||||
|
||||
### Scenario: Data Classification Integration
|
||||
|
||||
1. **Trigger**: Content classification system processes new emails
|
||||
2. **Analysis**: ML system categorizes email as "Confidential Financial Data"
|
||||
3. **Mapping**: Business rules map category to "Financial Confidential - 10 Year" label
|
||||
4. **Action**: Apply label via API
|
||||
5. **Result**: Automatic compliance with data retention policies
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Set up authentication** by creating an API key with appropriate permissions
|
||||
2. **Identify your use cases** and create corresponding retention labels through the UI
|
||||
3. **Test the API** with a few sample emails to understand the workflow
|
||||
4. **Implement your business logic** to identify which emails need which labels
|
||||
5. **Deploy your automation** with proper error handling and monitoring
|
||||
6. **Monitor results** and adjust your classification rules as needed
|
||||
|
||||
This automated approach ensures consistent retention policy enforcement while reducing manual administrative overhead.
|
||||
224
docs/enterprise/retention-labels/guide.md
Normal file
224
docs/enterprise/retention-labels/guide.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# Retention Labels: User Interface Guide
|
||||
|
||||
The retention labels management interface is located at **Dashboard → Compliance → Retention Labels**. It provides a comprehensive view of all configured labels and tools for creating, editing, deleting, and applying labels to individual archived emails.
|
||||
|
||||
## Overview
|
||||
|
||||
Retention labels provide item-level retention control, allowing administrators to override normal retention policies for specific emails with custom retention periods. This is particularly useful for legal holds, regulatory compliance, and preserving important business communications.
|
||||
|
||||
## Labels Table
|
||||
|
||||
The main page displays a table of all retention labels with the following columns:
|
||||
|
||||
- **Name:** The label name and its UUID displayed underneath for reference. If a description is provided, it appears below the name in smaller text.
|
||||
- **Retention Period:** The number of days emails with this label are retained, displayed as "X days".
|
||||
- **Status:** A badge indicating whether the label is:
|
||||
- **Enabled** (green badge): The label can be applied to new emails
|
||||
- **Disabled** (gray badge): The label cannot be applied to new emails but continues to govern already-labeled emails
|
||||
- **Created At:** The date the label was created, displayed in local date format.
|
||||
- **Actions:** Dropdown menu with Edit and Delete options for each label.
|
||||
|
||||
The table is sorted by creation date in ascending order by default.
|
||||
|
||||
## Creating a Label
|
||||
|
||||
Click the **"Create New"** button (with plus icon) above the table to open the creation dialog.
|
||||
|
||||
### Form Fields
|
||||
|
||||
- **Name** (Required): A unique, descriptive name for the label. Maximum 255 characters.
|
||||
- **Description** (Optional): A detailed explanation of the label's purpose or usage. Maximum 1000 characters.
|
||||
- **Retention Period (Days)** (Required): The number of days to retain emails with this label. Must be at least 1 day.
|
||||
|
||||
### Example Labels
|
||||
|
||||
- **Name:** "Legal Hold - Project Alpha"
|
||||
**Description:** "Extended retention for emails related to ongoing litigation regarding Project Alpha intellectual property dispute"
|
||||
**Retention Period:** 3650 days (10 years)
|
||||
|
||||
- **Name:** "Executive Communications"
|
||||
**Description:** "Preserve important emails from C-level executives beyond normal retention periods"
|
||||
**Retention Period:** 2555 days (7 years)
|
||||
|
||||
- **Name:** "Financial Records Q4 2025"
|
||||
**Retention Period:** 2190 days (6 years)
|
||||
|
||||
### Success and Error Handling
|
||||
|
||||
- **Success**: The dialog closes and a green success notification appears confirming the label was created.
|
||||
- **Name Conflict**: If a label with the same name already exists, an error notification will display.
|
||||
- **Validation Errors**: Missing required fields or invalid values will show inline validation messages.
|
||||
|
||||
## Editing a Label
|
||||
|
||||
Click the **Edit** option from the actions dropdown on any label row to open the edit dialog.
|
||||
|
||||
### Editable Fields
|
||||
|
||||
- **Name**: Can always be modified (subject to uniqueness constraint)
|
||||
- **Description**: Can always be modified
|
||||
- **Retention Period**: Can only be modified if the label has never been applied to any emails
|
||||
|
||||
### Retention Period Restrictions
|
||||
|
||||
The edit dialog shows a warning message: "Retention period cannot be modified if this label is currently applied to emails." If you attempt to change the retention period for a label that's in use, the system will return a conflict error and display an appropriate error message.
|
||||
|
||||
This restriction prevents tampering with active retention schedules and ensures compliance integrity.
|
||||
|
||||
### Update Process
|
||||
|
||||
1. Modify the desired fields
|
||||
2. Click **Save** to submit changes
|
||||
3. The system validates the changes and updates the label
|
||||
4. A success notification confirms the update
|
||||
|
||||
## Deleting a Label
|
||||
|
||||
Click the **Delete** option from the actions dropdown to open the deletion confirmation dialog.
|
||||
|
||||
### Smart Deletion Behavior
|
||||
|
||||
The system uses intelligent deletion logic:
|
||||
|
||||
#### Hard Delete
|
||||
|
||||
If the label has **never been applied** to any emails:
|
||||
|
||||
- The label is permanently removed from the system
|
||||
- Success message: "Label deleted successfully"
|
||||
|
||||
#### Soft Disable
|
||||
|
||||
If the label is **currently applied** to one or more emails:
|
||||
|
||||
- The label is marked as "Disabled" instead of being deleted
|
||||
- The label remains in the table with a "Disabled" status badge
|
||||
- Existing emails keep their retention schedule based on this label
|
||||
- The label cannot be applied to new emails
|
||||
- Success message: "Label disabled successfully"
|
||||
|
||||
### Confirmation Dialog
|
||||
|
||||
The deletion dialog shows:
|
||||
|
||||
- **Title**: "Delete Retention Label"
|
||||
- **Description**: Explains that this action cannot be undone and may disable the label if it's in use
|
||||
- **Cancel** button to abort the operation
|
||||
- **Confirm** button to proceed with deletion
|
||||
|
||||
## Applying Labels to Emails
|
||||
|
||||
Retention labels can be applied to individual archived emails through the email detail pages.
|
||||
|
||||
### From Email Detail Page
|
||||
|
||||
1. Navigate to an archived email by clicking on it from search results or the archived emails list
|
||||
2. Look for the "Retention Label" section in the email metadata
|
||||
3. If no label is applied, you'll see an "Apply Label" button (requires `delete:archive` permission)
|
||||
4. If a label is already applied, you'll see:
|
||||
- The current label name and retention period
|
||||
- "Change Label" and "Remove Label" buttons
|
||||
|
||||
### Label Application Process
|
||||
|
||||
1. Click **"Apply Label"** or **"Change Label"**
|
||||
2. A dropdown or dialog shows all available (enabled) labels
|
||||
3. Select the desired label
|
||||
4. Confirm the application
|
||||
5. The system:
|
||||
- Removes any existing label from the email
|
||||
- Applies the new label
|
||||
- Records the action in the audit log
|
||||
- Updates the email's retention schedule
|
||||
|
||||
### One Label Per Email Rule
|
||||
|
||||
Each email can have at most one retention label. When you apply a new label to an email that already has a label, the previous label is automatically removed and replaced with the new one.
|
||||
|
||||
## Permissions Required
|
||||
|
||||
Different operations require different permission levels:
|
||||
|
||||
### Label Management
|
||||
|
||||
- **Create, Edit, Delete Labels**: Requires `manage:all` permission
|
||||
- **View Labels Table**: Requires `manage:all` permission
|
||||
|
||||
### Email Label Operations
|
||||
|
||||
- **View Email Labels**: Requires `read:archive` permission
|
||||
- **Apply/Remove Email Labels**: Requires `delete:archive` permission
|
||||
|
||||
## Status Indicators
|
||||
|
||||
### Enabled Labels (Green Badge)
|
||||
|
||||
- Can be applied to new emails
|
||||
- Appears in label selection dropdowns
|
||||
- Fully functional for all operations
|
||||
|
||||
### Disabled Labels (Gray Badge)
|
||||
|
||||
- Cannot be applied to new emails
|
||||
- Does not appear in label selection dropdowns
|
||||
- Continues to govern retention for already-labeled emails
|
||||
- Can still be viewed and its details examined
|
||||
- Results from attempting to delete a label that's currently in use
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- Use descriptive names that indicate purpose: "Legal Hold - Case XYZ", "Executive - Q4 Review"
|
||||
- Include time periods or case references where relevant
|
||||
- Maintain consistent naming patterns across your organization
|
||||
|
||||
### Descriptions
|
||||
|
||||
- Always provide descriptions for complex or specialized labels
|
||||
- Include the business reason or legal requirement driving the retention period
|
||||
- Reference specific regulations, policies, or legal matters where applicable
|
||||
|
||||
### Retention Periods
|
||||
|
||||
- Consider your organization's legal and regulatory requirements
|
||||
- Common periods:
|
||||
- **3 years (1095 days)**: Standard business records
|
||||
- **7 years (2555 days)**: Financial and tax records
|
||||
- **10 years (3650 days)**: Legal holds and critical business documents
|
||||
- **Permanent retention**: Use very large numbers (e.g., 36500 days = 100 years)
|
||||
|
||||
### Label Lifecycle
|
||||
|
||||
- Review labels periodically to identify unused or obsolete labels
|
||||
- Disabled labels can accumulate over time - consider cleanup procedures
|
||||
- Document the purpose and expected lifecycle of each label for future administrators
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Cannot Edit Retention Period
|
||||
|
||||
**Problem**: Edit dialog shows retention period as locked or returns conflict error
|
||||
**Cause**: The label is currently applied to one or more emails
|
||||
**Solution**: Create a new label with the desired retention period instead of modifying the existing one
|
||||
|
||||
### Label Not Appearing in Email Application Dropdown
|
||||
|
||||
**Problem**: A label doesn't show up when trying to apply it to an email
|
||||
**Cause**: The label is disabled
|
||||
**Solution**: Check the labels table - disabled labels show a gray "Disabled" badge
|
||||
|
||||
### Cannot Delete Label
|
||||
|
||||
**Problem**: Deletion results in label being disabled instead of removed
|
||||
**Cause**: The label is currently applied to emails
|
||||
**Solution**: This is expected behavior to preserve retention integrity. The label can only be hard-deleted if it has never been used.
|
||||
|
||||
### Permission Denied Errors
|
||||
|
||||
**Problem**: Cannot access label management or apply labels to emails
|
||||
**Cause**: Insufficient permissions
|
||||
**Solution**: Contact your system administrator to verify you have the required permissions:
|
||||
|
||||
- `manage:all` for label management
|
||||
- `delete:archive` for email label operations
|
||||
117
docs/enterprise/retention-labels/index.md
Normal file
117
docs/enterprise/retention-labels/index.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Retention Labels
|
||||
|
||||
The Retention Labels feature is an enterprise-grade capability that provides item-level retention overrides for archived emails. Unlike retention policies which apply rules to groups of emails, retention labels are manually or programmatically applied to individual emails to override the normal retention lifecycle with specific retention periods.
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. Item-Level Retention Override
|
||||
|
||||
Retention labels represent a specific, targeted retention requirement that takes precedence over any automated retention policies. When an email has a retention label applied, the label's `retentionPeriodDays` becomes the governing retention period for that email, regardless of what any retention policy would otherwise specify.
|
||||
|
||||
### 2. One Label Per Email
|
||||
|
||||
Each archived email can have at most one retention label applied at any time. Applying a new label to an email automatically removes any existing label, ensuring a clean, unambiguous retention state.
|
||||
|
||||
### 3. Deletion Behavior
|
||||
|
||||
Retention labels implement the following deletion logic:
|
||||
|
||||
- **Hard Delete**: If a label has never been applied to any emails, it can be completely removed from the system.
|
||||
- **Soft Disable**: If a label is currently applied to one or more emails, deletion attempts result in the label being marked as `isDisabled = true`. This keeps the label-email relations but the retention label won't take effective.
|
||||
- **Delete Disabled Labels**: If a label is currently applied to one or more emails, and it is disabled, a deletion request will delete the label itself and all label-email relations (remove the label from emails it is tagged with).
|
||||
|
||||
### 4. Immutable Retention Period
|
||||
|
||||
Once a retention label has been applied to any email, its `retentionPeriodDays` value becomes immutable to prevent tampering with active retention schedules. Labels can only have their retention period modified while they have zero applications.
|
||||
|
||||
### 5. User Attribution and Audit Trail
|
||||
|
||||
Every label application and removal is attributed to a specific user and recorded in the [Audit Log](../audit-log/index.md). This includes both manual UI actions and automated API operations, ensuring complete traceability of retention decisions.
|
||||
|
||||
### 6. Lifecycle Integration
|
||||
|
||||
The [Lifecycle Worker](../retention-policy/lifecycle-worker.md) gives retention labels the highest priority during email evaluation. If an email has a retention label applied, the label's retention period is used instead of any matching retention policy rules.
|
||||
|
||||
## Feature Requirements
|
||||
|
||||
The Retention Labels feature requires:
|
||||
|
||||
- An active **Enterprise license** with the `RETENTION_POLICY` feature enabled.
|
||||
- The `manage:all` permission for administrative operations (creating, editing, deleting labels).
|
||||
- The `delete:archive` permission for applying and removing labels from individual emails.
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Legal Hold Alternative
|
||||
|
||||
Retention labels can serve as a lightweight alternative to formal legal holds by applying extended retention periods (e.g., 10+ years) to specific emails related to litigation or investigation.
|
||||
|
||||
### Executive Communications
|
||||
|
||||
Apply extended retention to emails from or to executive leadership to ensure important business communications are preserved beyond normal retention periods.
|
||||
|
||||
### Regulatory Exceptions
|
||||
|
||||
Mark specific emails that must be retained for regulatory compliance (e.g., financial records, safety incidents) with appropriate retention periods regardless of general policy rules.
|
||||
|
||||
### Project-Specific Retention
|
||||
|
||||
Apply custom retention periods to emails related to specific projects, contracts, or business initiatives that have unique preservation requirements.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The feature is composed of the following components:
|
||||
|
||||
| Component | Location | Description |
|
||||
| ----------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------- |
|
||||
| Types | `packages/types/src/retention.types.ts` | Shared TypeScript types for labels and email label info. |
|
||||
| Database Schema | `packages/backend/src/database/schema/compliance.ts` | Drizzle ORM table definitions for retention labels. |
|
||||
| Label Service | `packages/enterprise/src/modules/retention-policy/RetentionLabelService.ts` | CRUD operations and label application logic. |
|
||||
| API Controller | `packages/enterprise/src/modules/retention-policy/retention-label.controller.ts` | Express request handlers with Zod validation. |
|
||||
| API Routes | `packages/enterprise/src/modules/retention-policy/retention-policy.routes.ts` | Route registration with auth and feature guards. |
|
||||
| Frontend Page | `packages/frontend/src/routes/dashboard/compliance/retention-labels/` | SvelteKit page for label management. |
|
||||
| Email Integration | Individual archived email pages | Label application UI in email detail views. |
|
||||
|
||||
## Data Model
|
||||
|
||||
### Retention Labels Table
|
||||
|
||||
| Column | Type | Description |
|
||||
| ----------------------- | -------------- | ---------------------------------------------------------------- |
|
||||
| `id` | `uuid` (PK) | Auto-generated unique identifier. |
|
||||
| `name` | `varchar(255)` | Human-readable label name (unique constraint). |
|
||||
| `retention_period_days` | `integer` | Number of days to retain emails with this label. |
|
||||
| `description` | `text` | Optional description of the label's purpose. |
|
||||
| `is_disabled` | `boolean` | Whether the label is disabled (cannot be applied to new emails). |
|
||||
| `created_at` | `timestamptz` | Creation timestamp. |
|
||||
|
||||
### Email Label Applications Table
|
||||
|
||||
| Column | Type | Description |
|
||||
| -------------------- | ------------- | ------------------------------------------------------------- |
|
||||
| `email_id` | `uuid` (FK) | Reference to the archived email. |
|
||||
| `label_id` | `uuid` (FK) | Reference to the retention label. |
|
||||
| `applied_at` | `timestamptz` | Timestamp when the label was applied. |
|
||||
| `applied_by_user_id` | `uuid` (FK) | User who applied the label (nullable for API key operations). |
|
||||
|
||||
The table uses a composite primary key of `(email_id, label_id)` to enforce the one-label-per-email constraint at the database level.
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Lifecycle Worker
|
||||
|
||||
The lifecycle worker queries the `email_retention_labels` table during email evaluation. If an email has a retention label applied, the label's `retentionPeriodDays` takes precedence over any retention policy evaluation.
|
||||
|
||||
### Audit Log
|
||||
|
||||
All retention label operations generate audit log entries:
|
||||
|
||||
- **Label Creation**: Action type `CREATE`, target type `RetentionLabel`
|
||||
- **Label Updates**: Action type `UPDATE`, target type `RetentionLabel`
|
||||
- **Label Deletion/Disabling**: Action type `DELETE` or `UPDATE`, target type `RetentionLabel`
|
||||
- **Label Application**: Action type `UPDATE`, target type `ArchivedEmail`, details include label information
|
||||
- **Label Removal**: Action type `UPDATE`, target type `ArchivedEmail`, details include removed label information
|
||||
|
||||
### Email Detail Pages
|
||||
|
||||
Individual archived email pages display any applied retention label and provide controls for users with appropriate permissions to apply or remove labels.
|
||||
268
docs/enterprise/retention-policy/api.md
Normal file
268
docs/enterprise/retention-policy/api.md
Normal file
@@ -0,0 +1,268 @@
|
||||
# Retention Policy: API Endpoints
|
||||
|
||||
The retention policy feature exposes a RESTful API for managing retention policies and simulating policy evaluation against email metadata. All endpoints require authentication and the `manage:all` permission.
|
||||
|
||||
**Base URL:** `/api/v1/enterprise/retention-policy`
|
||||
|
||||
All endpoints also require the `RETENTION_POLICY` feature to be enabled in the enterprise license.
|
||||
|
||||
---
|
||||
|
||||
## List All Policies
|
||||
|
||||
Retrieves all retention policies, ordered by priority ascending.
|
||||
|
||||
- **Endpoint:** `GET /policies`
|
||||
- **Method:** `GET`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `manage:all`
|
||||
|
||||
### Response Body
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"name": "Default 7-Year Retention",
|
||||
"description": "Retain all emails for 7 years per regulatory requirements.",
|
||||
"priority": 1,
|
||||
"conditions": null,
|
||||
"ingestionScope": null,
|
||||
"retentionPeriodDays": 2555,
|
||||
"isActive": true,
|
||||
"createdAt": "2025-10-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-10-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Policy by ID
|
||||
|
||||
Retrieves a single retention policy by its UUID.
|
||||
|
||||
- **Endpoint:** `GET /policies/:id`
|
||||
- **Method:** `GET`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `manage:all`
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------ | ------------------------------ |
|
||||
| `id` | `uuid` | The UUID of the policy to get. |
|
||||
|
||||
### Response Body
|
||||
|
||||
Returns a single policy object (same shape as the list endpoint), or `404` if not found.
|
||||
|
||||
---
|
||||
|
||||
## Create Policy
|
||||
|
||||
Creates a new retention policy. The policy name must be unique across the system.
|
||||
|
||||
- **Endpoint:** `POST /policies`
|
||||
- **Method:** `POST`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `manage:all`
|
||||
|
||||
### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --------------------- | ------------------- | -------- | ---------------------------------------------------------------------------------------- |
|
||||
| `name` | `string` | Yes | Unique policy name. Max 255 characters. |
|
||||
| `description` | `string` | No | Human-readable description. Max 1000 characters. |
|
||||
| `priority` | `integer` | Yes | Positive integer. Lower values indicate higher priority. |
|
||||
| `retentionPeriodDays` | `integer` | Yes | Number of days to retain matching emails. Minimum 1. |
|
||||
| `actionOnExpiry` | `string` | Yes | Action to take when the retention period expires. Currently only `"delete_permanently"`. |
|
||||
| `isEnabled` | `boolean` | No | Whether the policy is active. Defaults to `true`. |
|
||||
| `conditions` | `RuleGroup \| null` | No | Condition rules for targeting specific emails. `null` matches all emails. |
|
||||
| `ingestionScope` | `string[] \| null` | No | Array of ingestion source UUIDs to scope the policy to. `null` applies to all sources. |
|
||||
|
||||
#### Conditions (RuleGroup) Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"logicalOperator": "AND",
|
||||
"rules": [
|
||||
{
|
||||
"field": "sender",
|
||||
"operator": "domain_match",
|
||||
"value": "example.com"
|
||||
},
|
||||
{
|
||||
"field": "subject",
|
||||
"operator": "contains",
|
||||
"value": "invoice"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Supported fields:** `sender`, `recipient`, `subject`, `attachment_type`
|
||||
|
||||
**Supported operators:**
|
||||
|
||||
| Operator | Description |
|
||||
| -------------- | ------------------------------------------------------------------- |
|
||||
| `equals` | Exact case-insensitive match. |
|
||||
| `not_equals` | Inverse of `equals`. |
|
||||
| `contains` | Case-insensitive substring match. |
|
||||
| `not_contains` | Inverse of `contains`. |
|
||||
| `starts_with` | Case-insensitive prefix match. |
|
||||
| `ends_with` | Case-insensitive suffix match. |
|
||||
| `domain_match` | Matches when an email address ends with `@<value>`. |
|
||||
| `regex_match` | ECMAScript regex (case-insensitive). Max pattern length: 200 chars. |
|
||||
|
||||
**Validation limits:**
|
||||
|
||||
- Maximum 50 rules per group.
|
||||
- Rule `value` must be between 1 and 500 characters.
|
||||
|
||||
### Example Request
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Finance Department - 10 Year",
|
||||
"description": "Extended retention for finance-related correspondence.",
|
||||
"priority": 2,
|
||||
"retentionPeriodDays": 3650,
|
||||
"actionOnExpiry": "delete_permanently",
|
||||
"conditions": {
|
||||
"logicalOperator": "OR",
|
||||
"rules": [
|
||||
{
|
||||
"field": "sender",
|
||||
"operator": "domain_match",
|
||||
"value": "finance.acme.com"
|
||||
},
|
||||
{
|
||||
"field": "recipient",
|
||||
"operator": "domain_match",
|
||||
"value": "finance.acme.com"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ingestionScope": ["b2c3d4e5-f6a7-8901-bcde-f23456789012"]
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
- **`201 Created`** — Returns the created policy object.
|
||||
- **`409 Conflict`** — A policy with this name already exists.
|
||||
- **`422 Unprocessable Entity`** — Validation errors.
|
||||
|
||||
---
|
||||
|
||||
## Update Policy
|
||||
|
||||
Updates an existing retention policy. Only the fields included in the request body are modified.
|
||||
|
||||
- **Endpoint:** `PUT /policies/:id`
|
||||
- **Method:** `PUT`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `manage:all`
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------ | --------------------------------- |
|
||||
| `id` | `uuid` | The UUID of the policy to update. |
|
||||
|
||||
### Request Body
|
||||
|
||||
All fields from the create endpoint are accepted, and all are optional. Only provided fields are updated.
|
||||
|
||||
To clear conditions (make the policy match all emails), send `"conditions": null`.
|
||||
|
||||
To clear ingestion scope (make the policy apply to all sources), send `"ingestionScope": null`.
|
||||
|
||||
### Response
|
||||
|
||||
- **`200 OK`** — Returns the updated policy object.
|
||||
- **`404 Not Found`** — Policy with the given ID does not exist.
|
||||
- **`422 Unprocessable Entity`** — Validation errors.
|
||||
|
||||
---
|
||||
|
||||
## Delete Policy
|
||||
|
||||
Permanently deletes a retention policy. This action is irreversible.
|
||||
|
||||
- **Endpoint:** `DELETE /policies/:id`
|
||||
- **Method:** `DELETE`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `manage:all`
|
||||
|
||||
### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------ | --------------------------------- |
|
||||
| `id` | `uuid` | The UUID of the policy to delete. |
|
||||
|
||||
### Response
|
||||
|
||||
- **`204 No Content`** — Policy successfully deleted.
|
||||
- **`404 Not Found`** — Policy with the given ID does not exist.
|
||||
|
||||
---
|
||||
|
||||
## Evaluate Email (Policy Simulator)
|
||||
|
||||
Evaluates a set of email metadata against all active policies and returns the applicable retention period and matching policy IDs. This endpoint does not modify any data — it is a read-only simulation tool.
|
||||
|
||||
- **Endpoint:** `POST /policies/evaluate`
|
||||
- **Method:** `POST`
|
||||
- **Authentication:** Required
|
||||
- **Permission:** `manage:all`
|
||||
|
||||
### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --------------------------------- | ---------- | -------- | ---------------------------------------------------------- |
|
||||
| `emailMetadata.sender` | `string` | Yes | Sender email address. Max 500 characters. |
|
||||
| `emailMetadata.recipients` | `string[]` | Yes | Recipient email addresses. Max 500 entries. |
|
||||
| `emailMetadata.subject` | `string` | Yes | Email subject line. Max 2000 characters. |
|
||||
| `emailMetadata.attachmentTypes` | `string[]` | Yes | File extensions (e.g., `[".pdf", ".xml"]`). Max 100. |
|
||||
| `emailMetadata.ingestionSourceId` | `uuid` | No | Optional ingestion source UUID for scope-aware evaluation. |
|
||||
|
||||
### Example Request
|
||||
|
||||
```json
|
||||
{
|
||||
"emailMetadata": {
|
||||
"sender": "cfo@finance.acme.com",
|
||||
"recipients": ["legal@acme.com"],
|
||||
"subject": "Q4 Invoice Reconciliation",
|
||||
"attachmentTypes": [".pdf", ".xlsx"],
|
||||
"ingestionSourceId": "b2c3d4e5-f6a7-8901-bcde-f23456789012"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response Body
|
||||
|
||||
```json
|
||||
{
|
||||
"appliedRetentionDays": 3650,
|
||||
"actionOnExpiry": "delete_permanently",
|
||||
"matchingPolicyIds": [
|
||||
"a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"c3d4e5f6-a7b8-9012-cdef-345678901234"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------------------- | ---------- | ------------------------------------------------------------------------------------- |
|
||||
| `appliedRetentionDays` | `integer` | The longest retention period from all matching policies. `0` means no policy matched. |
|
||||
| `actionOnExpiry` | `string` | The action to take on expiry. Currently always `"delete_permanently"`. |
|
||||
| `matchingPolicyIds` | `string[]` | UUIDs of all policies that matched the provided metadata. |
|
||||
|
||||
### Response Codes
|
||||
|
||||
- **`200 OK`** — Evaluation completed.
|
||||
- **`422 Unprocessable Entity`** — Validation errors in the request body.
|
||||
93
docs/enterprise/retention-policy/guide.md
Normal file
93
docs/enterprise/retention-policy/guide.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Retention Policy: User Interface
|
||||
|
||||
The retention policy management interface is located at **Dashboard → Compliance → Retention Policies**. It provides a comprehensive view of all configured policies and tools for creating, editing, deleting, and simulating retention rules.
|
||||
|
||||
## Policy Table
|
||||
|
||||
The main page displays a table of all retention policies with the following columns:
|
||||
|
||||
- **Name:** The policy name and its UUID displayed underneath for reference.
|
||||
- **Priority:** The numeric priority value. Lower values indicate higher priority.
|
||||
- **Retention Period:** The number of days emails matching this policy are retained before expiry.
|
||||
- **Ingestion Scope:** Shows which ingestion sources the policy is restricted to. Displays "All ingestion sources" when the policy has no scope restriction, or individual source name badges when scoped.
|
||||
- **Conditions:** A summary of the rule group. Displays "No conditions (matches all emails)" for policies without conditions, or "N rule(s) (AND/OR)" for policies with conditions.
|
||||
- **Status:** A badge indicating whether the policy is Active or Inactive.
|
||||
- **Actions:** Edit and Delete buttons for each policy.
|
||||
|
||||
The table is sorted by policy priority by default.
|
||||
|
||||
## Creating a Policy
|
||||
|
||||
Click the **"Create Policy"** button above the table to open the creation dialog. The form contains the following sections:
|
||||
|
||||
### Basic Information
|
||||
|
||||
- **Policy Name:** A unique, descriptive name for the policy.
|
||||
- **Description:** An optional detailed description of the policy's purpose.
|
||||
- **Priority:** A positive integer determining evaluation order (lower = higher priority).
|
||||
- **Retention Period (Days):** The number of days to retain matching emails.
|
||||
|
||||
### Ingestion Scope
|
||||
|
||||
This section controls which ingestion sources the policy applies to:
|
||||
|
||||
- **"All ingestion sources" toggle:** When enabled, the policy applies to emails from all ingestion sources. This is the default.
|
||||
- **Per-source checkboxes:** When the "all" toggle is disabled, individual ingestion sources can be selected. Each source displays its name and provider type as a badge.
|
||||
|
||||
### Condition Rules
|
||||
|
||||
Conditions define which emails the policy targets. If no conditions are added, the policy matches all emails (within its ingestion scope).
|
||||
|
||||
- **Logical Operator:** Choose **AND** (all rules must match) or **OR** (any rule must match).
|
||||
- **Add Rule:** Each rule consists of:
|
||||
- **Field:** The email metadata field to evaluate (`sender`, `recipient`, `subject`, or `attachment_type`).
|
||||
- **Operator:** The comparison operator (see [Supported Operators](#supported-operators) below).
|
||||
- **Value:** The string value to compare against.
|
||||
- **Remove Rule:** Each rule has a remove button to delete it from the group.
|
||||
|
||||
### Supported Operators
|
||||
|
||||
| Operator | Display Name | Description |
|
||||
| -------------- | ------------ | ---------------------------------------------------------------- |
|
||||
| `equals` | Equals | Exact case-insensitive match. |
|
||||
| `not_equals` | Not Equals | Inverse of equals. |
|
||||
| `contains` | Contains | Case-insensitive substring match. |
|
||||
| `not_contains` | Not Contains | Inverse of contains. |
|
||||
| `starts_with` | Starts With | Case-insensitive prefix match. |
|
||||
| `ends_with` | Ends With | Case-insensitive suffix match. |
|
||||
| `domain_match` | Domain Match | Matches when an email address ends with `@<value>`. |
|
||||
| `regex_match` | Regex Match | ECMAScript regular expression (case-insensitive, max 200 chars). |
|
||||
|
||||
### Policy Status
|
||||
|
||||
- **Enable Policy toggle:** Controls whether the policy is active immediately upon creation.
|
||||
|
||||
## Editing a Policy
|
||||
|
||||
Click the **Edit** button (pencil icon) on any policy row to open the edit dialog. The form is pre-populated with the policy's current values. All fields can be modified, and the same validation rules apply as during creation.
|
||||
|
||||
## Deleting a Policy
|
||||
|
||||
Click the **Delete** button (trash icon) on any policy row. A confirmation dialog appears to prevent accidental deletion. Deleting a policy is irreversible. Once deleted, the policy no longer affects the lifecycle worker's evaluation of emails.
|
||||
|
||||
## Policy Simulator
|
||||
|
||||
The **"Simulate Policy"** button opens a simulation tool that evaluates hypothetical email metadata against all active policies without making any changes.
|
||||
|
||||
### Simulator Input Fields
|
||||
|
||||
- **Sender Email:** The sender address to evaluate (e.g., `cfo@finance.acme.com`).
|
||||
- **Recipients:** A comma-separated list of recipient email addresses.
|
||||
- **Subject:** The email subject line.
|
||||
- **Attachment Types:** A comma-separated list of file extensions (e.g., `.pdf, .xlsx`).
|
||||
- **Ingestion Source:** An optional dropdown to select a specific ingestion source for scope-aware evaluation. Defaults to "All sources".
|
||||
|
||||
### Simulator Results
|
||||
|
||||
After submission, the simulator displays:
|
||||
|
||||
- **Applied Retention Period:** The longest retention period from all matching policies, displayed in days.
|
||||
- **Action on Expiry:** The action that would be taken when the retention period expires (currently always "Permanent Deletion").
|
||||
- **Matching Policies:** A list of all policy IDs (with their names) that matched the provided metadata. If no policies match, a message indicates that no matching policies were found.
|
||||
|
||||
The simulator is a safe, read-only tool intended for testing and verifying policy configurations before they affect live data.
|
||||
55
docs/enterprise/retention-policy/index.md
Normal file
55
docs/enterprise/retention-policy/index.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Retention Policy
|
||||
|
||||
The Retention Policy Engine is an enterprise-grade feature that automates the lifecycle management of archived emails. It enables organizations to define time-based retention rules that determine how long archived emails are kept before they are permanently deleted, ensuring compliance with data protection regulations and internal data governance policies.
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. Policy-Based Automation
|
||||
|
||||
Email deletion is never arbitrary. Every deletion is governed by one or more explicitly configured retention policies that define the retention period in days, the conditions under which the policy applies, and the action to take when an email expires. The lifecycle worker processes emails in batches on a recurring schedule, ensuring continuous enforcement without manual intervention.
|
||||
|
||||
### 2. Condition-Based Targeting
|
||||
|
||||
Policies can target specific subsets of archived emails using a flexible condition builder. Conditions are evaluated against email metadata fields (sender, recipient, subject, attachment type) using a variety of string-matching operators. Conditions within a policy are grouped using AND/OR logic, allowing precise control over which emails a policy applies to.
|
||||
|
||||
### 3. Ingestion Scope
|
||||
|
||||
Each policy can optionally be scoped to one or more ingestion sources. When an ingestion scope is set, the policy only applies to emails that were archived from those specific sources. Policies with no ingestion scope (null) apply to all emails regardless of their source.
|
||||
|
||||
### 4. Priority and Max-Duration-Wins
|
||||
|
||||
When multiple policies match a single email, the system applies **max-duration-wins** logic: the longest matching retention period is used. This ensures that if any policy requires an email to be kept longer, that requirement is honored. The priority field on each policy provides an ordering mechanism for administrative purposes and future conflict-resolution enhancements.
|
||||
|
||||
### 5. Full Audit Trail
|
||||
|
||||
Every policy lifecycle event — creation, modification, deletion, and every automated email deletion — is recorded in the immutable [Audit Log](../audit-log/index.md). Automated deletions include the IDs of the governing policies in the audit log entry, ensuring full traceability from deletion back to the rule that triggered it.
|
||||
|
||||
### 6. Fail-Safe Behavior
|
||||
|
||||
The system is designed to err on the side of caution:
|
||||
|
||||
- If no policy matches an email, the email is **not** deleted.
|
||||
- If the lifecycle worker encounters an error processing a specific email, it logs the error and continues with the remaining emails in the batch.
|
||||
- Invalid regex patterns in `regex_match` rules are treated as non-matching rather than causing failures.
|
||||
|
||||
## Feature Requirements
|
||||
|
||||
The Retention Policy Engine requires:
|
||||
|
||||
- An active **Enterprise license** with the `RETENTION_POLICY` feature enabled.
|
||||
- The `manage:all` permission for the authenticated user to access the policy management API and UI.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The feature is composed of the following components:
|
||||
|
||||
| Component | Location | Description |
|
||||
| ----------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| Types | `packages/types/src/retention.types.ts` | Shared TypeScript types for policies, rules, and evaluation. |
|
||||
| Database Schema | `packages/backend/src/database/schema/compliance.ts` | Drizzle ORM table definition for `retention_policies`. |
|
||||
| Retention Service | `packages/enterprise/src/modules/retention-policy/RetentionService.ts` | CRUD operations and the evaluation engine. |
|
||||
| API Controller | `packages/enterprise/src/modules/retention-policy/retention-policy.controller.ts` | Express request handlers with Zod validation. |
|
||||
| API Routes | `packages/enterprise/src/modules/retention-policy/retention-policy.routes.ts` | Route registration with auth and feature guards. |
|
||||
| Module | `packages/enterprise/src/modules/retention-policy/retention-policy.module.ts` | Enterprise module bootstrap. |
|
||||
| Lifecycle Worker | `packages/enterprise/src/workers/lifecycle.worker.ts` | BullMQ worker for automated retention enforcement. |
|
||||
| Frontend Page | `packages/frontend/src/routes/dashboard/compliance/retention-policies/` | SvelteKit page for policy management and simulation. |
|
||||
108
docs/enterprise/retention-policy/lifecycle-worker.md
Normal file
108
docs/enterprise/retention-policy/lifecycle-worker.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Retention Policy: Lifecycle Worker
|
||||
|
||||
The lifecycle worker is the automated enforcement component of the retention policy engine. It runs as a BullMQ background worker that periodically scans all archived emails, evaluates them against active retention policies, and permanently deletes emails that have exceeded their retention period.
|
||||
|
||||
## Location
|
||||
|
||||
`packages/enterprise/src/workers/lifecycle.worker.ts`
|
||||
|
||||
## How It Works
|
||||
|
||||
### Scheduling
|
||||
|
||||
The lifecycle worker is registered as a repeatable BullMQ cron job on the `compliance-lifecycle` queue. It is scheduled to run daily at **02:00 UTC** by default. The cron schedule is configured via:
|
||||
|
||||
```typescript
|
||||
repeat: {
|
||||
pattern: '0 2 * * *';
|
||||
} // daily at 02:00 UTC
|
||||
```
|
||||
|
||||
The `scheduleLifecycleJob()` function is called once during enterprise application startup to register the repeatable job with BullMQ.
|
||||
|
||||
### Batch Processing
|
||||
|
||||
To avoid loading the entire `archived_emails` table into memory, the worker processes emails in configurable batches:
|
||||
|
||||
1. **Batch size** is controlled by the `RETENTION_BATCH_SIZE` environment variable.
|
||||
2. Emails are ordered by `archivedAt` ascending.
|
||||
3. The worker iterates through batches using offset-based pagination until an empty batch is returned, indicating all emails have been processed.
|
||||
|
||||
### Per-Email Processing Flow
|
||||
|
||||
For each email in a batch, the worker:
|
||||
|
||||
1. **Extracts metadata:** Builds a `PolicyEvaluationRequest` from the email's database record:
|
||||
- `sender`: The sender email address.
|
||||
- `recipients`: All To, CC, and BCC recipient addresses.
|
||||
- `subject`: The email subject line.
|
||||
- `attachmentTypes`: File extensions (e.g., `.pdf`) extracted from attachment filenames via a join query.
|
||||
- `ingestionSourceId`: The UUID of the ingestion source that archived this email.
|
||||
|
||||
2. **Evaluates policies:** Passes the metadata to `RetentionService.evaluateEmail()`, which returns:
|
||||
- `appliedRetentionDays`: The longest matching retention period (0 if no policy matches).
|
||||
- `matchingPolicyIds`: UUIDs of all matching policies.
|
||||
|
||||
3. **Checks for expiry:**
|
||||
- If `appliedRetentionDays === 0`, no policy matched — the email is **skipped** (not deleted).
|
||||
- Otherwise, the email's age is calculated from its `sentAt` date.
|
||||
- If the age in days exceeds `appliedRetentionDays`, the email has expired.
|
||||
|
||||
4. **Deletes expired emails:** Calls `ArchivedEmailService.deleteArchivedEmail()` with:
|
||||
- `systemDelete: true` — Bypasses the `ENABLE_DELETION` configuration guard so retention enforcement always works regardless of that global setting.
|
||||
- `governingRule` — A string listing the matching policy IDs for the audit log entry (e.g., `"Policy IDs: abc-123, def-456"`).
|
||||
|
||||
5. **Logs the deletion:** A structured log entry records the email ID and its age in days.
|
||||
|
||||
### Error Handling
|
||||
|
||||
If processing a specific email fails (e.g., due to a database error or storage issue), the error is logged and the worker continues to the next email in the batch. This ensures that a single problematic email does not block the processing of the remaining emails.
|
||||
|
||||
If the entire job fails, BullMQ records the failure and the job ID and error are logged. Failed jobs are retained (up to 50) for debugging.
|
||||
|
||||
## System Actor
|
||||
|
||||
Automated deletions are attributed to a synthetic system actor in the audit log:
|
||||
|
||||
| Field | Value |
|
||||
| -------- | ------------------------------- |
|
||||
| ID | `system:lifecycle-worker` |
|
||||
| Email | `system@open-archiver.internal` |
|
||||
| Name | System Lifecycle Worker |
|
||||
| Actor IP | `system` |
|
||||
|
||||
This well-known identifier can be filtered in the [Audit Log](../audit-log/index.md) to view all retention-based deletions.
|
||||
|
||||
## Audit Trail
|
||||
|
||||
Every email deleted by the lifecycle worker produces an audit log entry with:
|
||||
|
||||
- **Action type:** `DELETE`
|
||||
- **Target type:** `ArchivedEmail`
|
||||
- **Target ID:** The UUID of the deleted email
|
||||
- **Actor:** `system:lifecycle-worker`
|
||||
- **Details:** Includes `reason: "RetentionExpiration"` and `governingRule` listing the matching policy IDs
|
||||
|
||||
This ensures that every automated deletion is fully traceable back to the specific policies that triggered it.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
| ---------------------- | ------------------------------------------------ | ------- |
|
||||
| `RETENTION_BATCH_SIZE` | Number of emails to process per batch iteration. | — |
|
||||
|
||||
## BullMQ Worker Settings
|
||||
|
||||
| Setting | Value | Description |
|
||||
| ------------------ | ---------------------- | ------------------------------------------ |
|
||||
| Queue name | `compliance-lifecycle` | The BullMQ queue name. |
|
||||
| Job ID | `lifecycle-daily` | Stable job ID for the repeatable cron job. |
|
||||
| `removeOnComplete` | Keep last 10 | Completed jobs retained for monitoring. |
|
||||
| `removeOnFail` | Keep last 50 | Failed jobs retained for debugging. |
|
||||
|
||||
## Integration with Deletion Guard
|
||||
|
||||
The core `ArchivedEmailService.deleteArchivedEmail()` method includes a deletion guard controlled by the `ENABLE_DELETION` system setting. When called with `systemDelete: true`, the lifecycle worker bypasses this guard. This design ensures that:
|
||||
|
||||
- Manual user deletions can be disabled organization-wide via the system setting.
|
||||
- Automated retention enforcement always operates regardless of that setting, because retention compliance is a legal obligation that cannot be paused by a UI toggle.
|
||||
141
docs/enterprise/retention-policy/retention-service.md
Normal file
141
docs/enterprise/retention-policy/retention-service.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Retention Policy: Backend Implementation
|
||||
|
||||
The backend implementation of the retention policy engine is handled by the `RetentionService`, located in `packages/enterprise/src/modules/retention-policy/RetentionService.ts`. This service encapsulates all CRUD operations for policies and the core evaluation engine that determines which policies apply to a given email.
|
||||
|
||||
## Database Schema
|
||||
|
||||
The `retention_policies` table is defined in `packages/backend/src/database/schema/compliance.ts` using Drizzle ORM:
|
||||
|
||||
| Column | Type | Description |
|
||||
| ----------------------- | ------------------------- | ------------------------------------------------------------- |
|
||||
| `id` | `uuid` (PK) | Auto-generated unique identifier. |
|
||||
| `name` | `text` (unique, not null) | Human-readable policy name. |
|
||||
| `description` | `text` | Optional description. |
|
||||
| `priority` | `integer` (not null) | Priority for ordering. Lower = higher priority. |
|
||||
| `retention_period_days` | `integer` (not null) | Number of days to retain matching emails. |
|
||||
| `action_on_expiry` | `enum` (not null) | Action on expiry (`delete_permanently`). |
|
||||
| `is_enabled` | `boolean` (default: true) | Whether the policy is active. |
|
||||
| `conditions` | `jsonb` | Serialized `RetentionRuleGroup` or null (null = matches all). |
|
||||
| `ingestion_scope` | `jsonb` | Array of ingestion source UUIDs or null (null = all sources). |
|
||||
| `created_at` | `timestamptz` | Creation timestamp. |
|
||||
| `updated_at` | `timestamptz` | Last update timestamp. |
|
||||
|
||||
## CRUD Operations
|
||||
|
||||
The `RetentionService` class provides the following methods:
|
||||
|
||||
### `createPolicy(data, actorId, actorIp)`
|
||||
|
||||
Inserts a new policy into the database and creates an audit log entry with action type `CREATE` and target type `RetentionPolicy`. The audit log details include the policy name, retention period, priority, action on expiry, and ingestion scope.
|
||||
|
||||
### `getPolicies()`
|
||||
|
||||
Returns all policies ordered by priority ascending. The raw database rows are mapped through `mapDbPolicyToType()`, which converts the DB column `isEnabled` to the shared type field `isActive` and normalizes date fields to ISO strings.
|
||||
|
||||
### `getPolicyById(id)`
|
||||
|
||||
Returns a single policy by UUID, or null if not found.
|
||||
|
||||
### `updatePolicy(id, data, actorId, actorIp)`
|
||||
|
||||
Partially updates a policy — only fields present in the DTO are modified. The `updatedAt` timestamp is always set to the current time. An audit log entry is created with action type `UPDATE`, recording which fields were changed.
|
||||
|
||||
Throws an error if the policy is not found.
|
||||
|
||||
### `deletePolicy(id, actorId, actorIp)`
|
||||
|
||||
Deletes a policy by UUID and creates an audit log entry with action type `DELETE`, recording the deleted policy's name. Returns `false` if the policy was not found.
|
||||
|
||||
## Evaluation Engine
|
||||
|
||||
The evaluation engine is the core logic that determines which policies apply to a given email. It is used by both the lifecycle worker (for automated enforcement) and the policy simulator endpoint (for testing).
|
||||
|
||||
### `evaluateEmail(metadata)`
|
||||
|
||||
This is the primary evaluation method. It accepts email metadata and returns:
|
||||
|
||||
- `appliedRetentionDays`: The longest matching retention period (max-duration-wins).
|
||||
- `matchingPolicyIds`: UUIDs of all policies that matched.
|
||||
- `actionOnExpiry`: Always `"delete_permanently"` in the current implementation.
|
||||
|
||||
The evaluation flow:
|
||||
|
||||
1. **Fetch active policies:** Queries all policies where `isEnabled = true`.
|
||||
2. **Ingestion scope check:** For each policy with a non-null `ingestionScope`, the email's `ingestionSourceId` must be included in the scope array. If not, the policy is skipped.
|
||||
3. **Condition evaluation:** If the policy has no conditions (`null`), it matches all emails within scope. Otherwise, the condition rule group is evaluated.
|
||||
4. **Max-duration-wins:** If multiple policies match, the longest `retentionPeriodDays` is used.
|
||||
5. **Zero means no match:** A return value of `appliedRetentionDays = 0` indicates no policy matched — the lifecycle worker will not delete the email.
|
||||
|
||||
### `_evaluateRuleGroup(group, metadata)`
|
||||
|
||||
Evaluates a `RetentionRuleGroup` using AND or OR logic:
|
||||
|
||||
- **AND:** Every rule in the group must pass.
|
||||
- **OR:** At least one rule must pass.
|
||||
- An empty rules array evaluates to `true`.
|
||||
|
||||
### `_evaluateRule(rule, metadata)`
|
||||
|
||||
Evaluates a single rule against the email metadata. All string comparisons are case-insensitive (both sides are lowercased before comparison). The behavior depends on the field:
|
||||
|
||||
| Field | Behavior |
|
||||
| ----------------- | ------------------------------------------------------------------- |
|
||||
| `sender` | Compares against the sender email address. |
|
||||
| `recipient` | Passes if **any** recipient matches the operator. |
|
||||
| `subject` | Compares against the email subject. |
|
||||
| `attachment_type` | Passes if **any** attachment file extension matches (e.g., `.pdf`). |
|
||||
|
||||
### `_applyOperator(haystack, operator, needle)`
|
||||
|
||||
Applies a string-comparison operator between two pre-lowercased strings:
|
||||
|
||||
| Operator | Implementation |
|
||||
| -------------- | ----------------------------------------------------------------------- |
|
||||
| `equals` | `haystack === needle` |
|
||||
| `not_equals` | `haystack !== needle` |
|
||||
| `contains` | `haystack.includes(needle)` |
|
||||
| `not_contains` | `!haystack.includes(needle)` |
|
||||
| `starts_with` | `haystack.startsWith(needle)` |
|
||||
| `ends_with` | `haystack.endsWith(needle)` |
|
||||
| `domain_match` | `haystack.endsWith('@' + needle)` (auto-prepends `@` if missing) |
|
||||
| `regex_match` | `new RegExp(needle, 'i').test(haystack)` with safety guards (see below) |
|
||||
|
||||
### Security: `regex_match` Safeguards
|
||||
|
||||
The `regex_match` operator includes protections against Regular Expression Denial of Service (ReDoS):
|
||||
|
||||
1. **Length limit:** Patterns exceeding 200 characters (`MAX_REGEX_LENGTH`) are rejected and treated as non-matching. A warning is logged.
|
||||
2. **Error handling:** Invalid regex syntax is caught in a try/catch block and treated as non-matching. A warning is logged.
|
||||
3. **Flags:** Only the case-insensitive flag (`i`) is used. Global and multiline flags are excluded to prevent stateful matching bugs.
|
||||
|
||||
## Request Validation
|
||||
|
||||
The `RetentionPolicyController` (`retention-policy.controller.ts`) validates all incoming requests using Zod schemas before passing data to the service:
|
||||
|
||||
| Constraint | Limit |
|
||||
| --------------------------- | -------------------------------------------------------------- |
|
||||
| Policy name | 1–255 characters. |
|
||||
| Description | Max 1000 characters. |
|
||||
| Priority | Positive integer (≥ 1). |
|
||||
| Retention period | Positive integer (≥ 1 day). |
|
||||
| Rules per group | Max 50. |
|
||||
| Rule value | 1–500 characters. |
|
||||
| Ingestion scope entries | Each must be a valid UUID. Empty arrays are coerced to `null`. |
|
||||
| Evaluate — sender | Max 500 characters. |
|
||||
| Evaluate — recipients | Max 500 entries, each max 500 characters. |
|
||||
| Evaluate — subject | Max 2000 characters. |
|
||||
| Evaluate — attachment types | Max 100 entries, each max 50 characters. |
|
||||
|
||||
## Module Registration
|
||||
|
||||
The `RetentionPolicyModule` (`retention-policy.module.ts`) implements the `ArchiverModule` interface and registers the API routes at:
|
||||
|
||||
```
|
||||
/{api.version}/enterprise/retention-policy
|
||||
```
|
||||
|
||||
All routes are protected by:
|
||||
|
||||
1. `requireAuth` — Ensures the request includes a valid authentication token.
|
||||
2. `featureEnabled(OpenArchiverFeature.RETENTION_POLICY)` — Ensures the enterprise license includes the retention policy feature.
|
||||
3. `requirePermission('manage', 'all')` — Ensures the user has administrative permissions.
|
||||
42
docs/index.md
Normal file
42
docs/index.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Get Started 👋
|
||||
|
||||
Welcome to Open Archiver! This guide will help you get started with setting up and using the platform.
|
||||
|
||||
## What is Open Archiver?
|
||||
|
||||
**A secure, sovereign, and affordable open-source platform for email archiving and eDiscovery.**
|
||||
|
||||
Open Archiver provides a robust, self-hosted solution for archiving, storing, indexing, and searching emails from major platforms, including Google Workspace (Gmail), Microsoft 365, as well as generic IMAP-enabled email inboxes. Use Open Archiver to keep a permanent, tamper-proof record of your communication history, free from vendor lock-in.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Universal Ingestion**: Connect to Google Workspace, Microsoft 365, and standard IMAP servers to perform initial bulk imports and maintain continuous, real-time synchronization.
|
||||
- **Secure & Efficient Storage**: Emails are stored in the standard `.eml` format. The system uses deduplication and compression to minimize storage costs. All data is encrypted at rest.
|
||||
- **Pluggable Storage Backends**: Support both local filesystem storage and S3-compatible object storage (like AWS S3 or MinIO).
|
||||
- **Powerful Search & eDiscovery**: A high-performance search engine indexes the full text of emails and attachments (PDF, DOCX, etc.).
|
||||
- **Compliance & Retention**: Define granular retention policies to automatically manage the lifecycle of your data. Place legal holds on communications to prevent deletion during litigation (TBD).
|
||||
- **Comprehensive Auditing**: An immutable audit trail logs all system activities, ensuring you have a clear record of who accessed what and when (TBD).
|
||||
|
||||
## Installation
|
||||
|
||||
To get your own instance of Open Archiver running, follow our detailed installation guide:
|
||||
|
||||
- [Installation Guide](./user-guides/installation.md)
|
||||
|
||||
## Data Source Configuration 🔌
|
||||
|
||||
After deploying the application, you will need to configure one or more ingestion sources to begin archiving emails. Follow our detailed guides to connect to your email provider:
|
||||
|
||||
- [Connecting to Google Workspace](./user-guides/email-providers/google-workspace.md)
|
||||
- [Connecting to Microsoft 365](./user-guides/email-providers/microsoft-365.md)
|
||||
- [Connecting to a Generic IMAP Server](./user-guides/email-providers/imap.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions from the community!
|
||||
|
||||
- **Reporting Bugs**: If you find a bug, please open an issue on our GitHub repository.
|
||||
- **Suggesting Enhancements**: Have an idea for a new feature? We'd love to hear it. Open an issue to start the discussion.
|
||||
- **Code Contributions**: If you'd like to contribute code, please fork the repository and submit a pull request.
|
||||
|
||||
Please read our `CONTRIBUTING.md` file for more details on our code of conduct and the process for submitting pull requests.
|
||||
19
docs/public/logo-sq.svg
Normal file
19
docs/public/logo-sq.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 164 KiB |
289
docs/services/iam-service/iam-policy.md
Normal file
289
docs/services/iam-service/iam-policy.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# IAM Policy
|
||||
|
||||
This document provides a guide to creating and managing IAM policies in Open Archiver. It is intended for developers and administrators who need to configure granular access control for users and roles.
|
||||
|
||||
## Policy Structure
|
||||
|
||||
IAM policies are defined as an array of JSON objects, where each object represents a single permission rule. The structure of a policy object is as follows:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "read" OR ["read", "create"],
|
||||
"subject": "ingestion" OR ["ingestion", "dashboard"],
|
||||
"conditions": {
|
||||
"field_name": "value"
|
||||
},
|
||||
"inverted": false OR true,
|
||||
}
|
||||
```
|
||||
|
||||
- `action`: The action(s) to be performed on the subject. Can be a single string or an array of strings.
|
||||
- `subject`: The resource(s) or entity on which the action is to be performed. Can be a single string or an array of strings.
|
||||
- `conditions`: (Optional) A set of conditions that must be met for the permission to be granted.
|
||||
- `inverted`: (Optional) When set to `true`, this inverts the rule, turning it from a "can" rule into a "cannot" rule. This is useful for creating exceptions to broader permissions.
|
||||
|
||||
## Actions
|
||||
|
||||
The following actions are available for use in IAM policies:
|
||||
|
||||
- `manage`: A wildcard action that grants all permissions on a subject (`create`, `read`, `update`, `delete`, `search`, `sync`).
|
||||
- `create`: Allows the user to create a new resource.
|
||||
- `read`: Allows the user to view a resource.
|
||||
- `update`: Allows the user to modify an existing resource.
|
||||
- `delete`: Allows the user to delete a resource.
|
||||
- `search`: Allows the user to search for resources.
|
||||
- `sync`: Allows the user to synchronize a resource.
|
||||
|
||||
## Subjects
|
||||
|
||||
The following subjects are available for use in IAM policies:
|
||||
|
||||
- `all`: A wildcard subject that represents all resources.
|
||||
- `archive`: Represents archived emails.
|
||||
- `ingestion`: Represents ingestion sources.
|
||||
- `settings`: Represents system settings.
|
||||
- `users`: Represents user accounts.
|
||||
- `roles`: Represents user roles.
|
||||
- `dashboard`: Represents the dashboard.
|
||||
|
||||
## Advanced Conditions with MongoDB-Style Queries
|
||||
|
||||
Conditions are the key to creating fine-grained access control rules. They are defined as a JSON object where each key represents a field on the subject, and the value defines the criteria for that field.
|
||||
|
||||
All conditions within a single rule are implicitly joined with an **AND** logic. This means that for a permission to be granted, the resource must satisfy _all_ specified conditions.
|
||||
|
||||
The power of this system comes from its use of a subset of [MongoDB's query language](https://www.mongodb.com/docs/manual/), which provides a flexible and expressive way to define complex rules. These rules are translated into native queries for both the PostgreSQL database (via Drizzle ORM) and the Meilisearch engine.
|
||||
|
||||
### Supported Operators and Examples
|
||||
|
||||
Here is a detailed breakdown of the supported operators with examples.
|
||||
|
||||
#### `$eq` (Equal)
|
||||
|
||||
This is the default operator. If you provide a simple key-value pair, it is treated as an equality check.
|
||||
|
||||
```json
|
||||
// This rule...
|
||||
{ "status": "active" }
|
||||
|
||||
// ...is equivalent to this:
|
||||
{ "status": { "$eq": "active" } }
|
||||
```
|
||||
|
||||
**Use Case**: Grant access to an ingestion source only if its status is `active`.
|
||||
|
||||
#### `$ne` (Not Equal)
|
||||
|
||||
Matches documents where the field value is not equal to the specified value.
|
||||
|
||||
```json
|
||||
{ "provider": { "$ne": "pst_import" } }
|
||||
```
|
||||
|
||||
**Use Case**: Allow a user to see all ingestion sources except for PST imports.
|
||||
|
||||
#### `$in` (In Array)
|
||||
|
||||
Matches documents where the field value is one of the values in the specified array.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": {
|
||||
"$in": ["INGESTION_ID_1", "INGESTION_ID_2"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Use Case**: Grant an auditor access to a specific list of ingestion sources.
|
||||
|
||||
#### `$nin` (Not In Array)
|
||||
|
||||
Matches documents where the field value is not one of the values in the specified array.
|
||||
|
||||
```json
|
||||
{ "provider": { "$nin": ["pst_import", "eml_import"] } }
|
||||
```
|
||||
|
||||
**Use Case**: Hide all manual import sources from a specific user role.
|
||||
|
||||
#### `$lt` / `$lte` (Less Than / Less Than or Equal)
|
||||
|
||||
Matches documents where the field value is less than (`$lt`) or less than or equal to (`$lte`) the specified value. This is useful for numeric or date-based comparisons.
|
||||
|
||||
```json
|
||||
{ "sentAt": { "$lt": "2024-01-01T00:00:00.000Z" } }
|
||||
```
|
||||
|
||||
#### `$gt` / `$gte` (Greater Than / Greater Than or Equal)
|
||||
|
||||
Matches documents where the field value is greater than (`$gt`) or greater than or equal to (`$gte`) the specified value.
|
||||
|
||||
```json
|
||||
{ "sentAt": { "$lt": "2024-01-01T00:00:00.000Z" } }
|
||||
```
|
||||
|
||||
#### `$exists`
|
||||
|
||||
Matches documents that have (or do not have) the specified field.
|
||||
|
||||
```json
|
||||
// Grant access only if a 'lastSyncStatusMessage' exists
|
||||
{ "lastSyncStatusMessage": { "$exists": true } }
|
||||
```
|
||||
|
||||
## Inverted Rules: Creating Exceptions with `cannot`
|
||||
|
||||
By default, all rules are "can" rules, meaning they grant permissions. However, you can create a "cannot" rule by adding `"inverted": true` to a policy object. This is extremely useful for creating exceptions to broader permissions.
|
||||
|
||||
A common pattern is to grant broad access and then use an inverted rule to carve out a specific restriction.
|
||||
|
||||
**Use Case**: Grant a user access to all ingestion sources _except_ for one specific source.
|
||||
|
||||
This is achieved with two rules:
|
||||
|
||||
1. A "can" rule that grants `read` access to the `ingestion` subject.
|
||||
2. An inverted "cannot" rule that denies `read` access for the specific ingestion `id`.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action": "read",
|
||||
"subject": "ingestion"
|
||||
},
|
||||
{
|
||||
"inverted": true,
|
||||
"action": "read",
|
||||
"subject": "ingestion",
|
||||
"conditions": {
|
||||
"id": "SPECIFIC_INGESTION_ID_TO_EXCLUDE"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Policy Evaluation Logic
|
||||
|
||||
The system evaluates policies by combining all relevant rules for a user. The logic is simple:
|
||||
|
||||
- A user has permission if at least one `can` rule allows it.
|
||||
- A permission is denied if a `cannot` (`"inverted": true`) rule explicitly forbids it, even if a `can` rule allows it. `cannot` rules always take precedence.
|
||||
|
||||
### Dynamic Policies with Placeholders
|
||||
|
||||
To create dynamic policies that are specific to the current user, you can use the `${user.id}` placeholder in the `conditions` object. This placeholder will be replaced with the ID of the current user at runtime.
|
||||
|
||||
## Special Permissions for User and Role Management
|
||||
|
||||
It is important to note that while `read` access to `users` and `roles` can be granted granularly, any actions that modify these resources (`create`, `update`, `delete`) are restricted to Super Admins.
|
||||
|
||||
A user must have the `{ "action": "manage", "subject": "all" }` permission (Typically a Super Admin role) to manage users and roles. This is a security measure to prevent unauthorized changes to user accounts and permissions.
|
||||
|
||||
## Policy Examples
|
||||
|
||||
Here are several examples based on the default roles in the system, demonstrating how to combine actions, subjects, and conditions to achieve specific access control scenarios.
|
||||
|
||||
### Administrator
|
||||
|
||||
This policy grants a user full access to all resources using wildcards.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action": "manage",
|
||||
"subject": "all"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### End-User
|
||||
|
||||
This policy allows a user to view the dashboard, create new ingestion sources, and fully manage the ingestion sources they own.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action": "read",
|
||||
"subject": "dashboard"
|
||||
},
|
||||
{
|
||||
"action": "create",
|
||||
"subject": "ingestion"
|
||||
},
|
||||
{
|
||||
"action": "manage",
|
||||
"subject": "ingestion",
|
||||
"conditions": {
|
||||
"userId": "${user.id}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "manage",
|
||||
"subject": "archive",
|
||||
"conditions": {
|
||||
"ingestionSource.userId": "${user.id}" // also needs to give permission to archived emails created by the user
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Global Read-Only Auditor
|
||||
|
||||
This policy grants read and search access across most of the application's resources, making it suitable for an auditor who needs to view data without modifying it.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action": ["read", "search"],
|
||||
"subject": ["ingestion", "archive", "dashboard", "users", "roles"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Ingestion Admin
|
||||
|
||||
This policy grants full control over all ingestion sources and archives, but no other resources.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action": "manage",
|
||||
"subject": "ingestion"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Auditor for Specific Ingestion Sources
|
||||
|
||||
This policy demonstrates how to grant access to a specific list of ingestion sources using the `$in` operator.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action": ["read", "search"],
|
||||
"subject": "ingestion",
|
||||
"conditions": {
|
||||
"id": {
|
||||
"$in": ["INGESTION_ID_1", "INGESTION_ID_2"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Limit Access to a Specific Mailbox
|
||||
|
||||
This policy grants a user access to a specific ingestion source, but only allows them to see emails belonging to a single user within that source.
|
||||
|
||||
This is achieved by defining two specific `can` rules: The rule grants `read` and `search` access to the `archive` subject, but the `userEmail` must match.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"action": ["read", "search"],
|
||||
"subject": "archive",
|
||||
"conditions": {
|
||||
"userEmail": "user1@example.com"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
1
docs/services/index.md
Normal file
1
docs/services/index.md
Normal file
@@ -0,0 +1 @@
|
||||
# services
|
||||
81
docs/services/job-queue.md
Normal file
81
docs/services/job-queue.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Job Queue Service
|
||||
|
||||
This document describes the architecture of the job queue system, including the sync cycle coordination mechanism and relevant configuration options.
|
||||
|
||||
## Architecture
|
||||
|
||||
The job queue system is built on [BullMQ](https://docs.bullmq.io/) backed by Redis (Valkey). Two worker processes run independently:
|
||||
|
||||
- **Ingestion worker** (`ingestion.worker.ts`) — processes the `ingestion` queue
|
||||
- **Indexing worker** (`indexing.worker.ts`) — processes the `indexing` queue
|
||||
|
||||
### Queues
|
||||
|
||||
| Queue | Jobs | Purpose |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------- |
|
||||
| `ingestion` | `schedule-continuous-sync`, `continuous-sync`, `initial-import`, `process-mailbox`, `sync-cycle-finished` | Email ingestion and sync orchestration |
|
||||
| `indexing` | `index-email-batch` | Meilisearch document indexing |
|
||||
|
||||
### Job Flow
|
||||
|
||||
```
|
||||
[schedule-continuous-sync] (repeating cron)
|
||||
└→ [continuous-sync] (per ingestion source)
|
||||
└→ [process-mailbox] × N (one per user mailbox)
|
||||
└→ [index-email-batch] (batched, on indexing queue)
|
||||
└→ [sync-cycle-finished] (dispatched by the last mailbox job)
|
||||
```
|
||||
|
||||
For initial imports, `initial-import` triggers the same `process-mailbox` → `sync-cycle-finished` flow.
|
||||
|
||||
## Sync Cycle Coordination
|
||||
|
||||
Sync cycle completion (knowing when all mailboxes in a sync have finished) is coordinated via the `sync_sessions` PostgreSQL table rather than BullMQ's built-in flow/parent-child system.
|
||||
|
||||
**Why:** BullMQ's `FlowProducer` stores the entire parent/child relationship in Redis atomically. For large tenants with thousands of mailboxes, this creates large Redis writes and requires loading all child job return values into memory at once for aggregation.
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. When `initial-import` or `continuous-sync` starts, it creates a `sync_sessions` row with `total_mailboxes = N`.
|
||||
2. Each `process-mailbox` job atomically increments `completed_mailboxes` or `failed_mailboxes` when it finishes, and merges its `SyncState` into `ingestion_sources.sync_state` using PostgreSQL's `||` jsonb operator.
|
||||
3. The job that brings `completed + failed` to equal `total` dispatches the `sync-cycle-finished` job.
|
||||
4. `sync-cycle-finished` reads the aggregated results from the session row and finalizes the source status.
|
||||
5. The session row is deleted after finalization.
|
||||
|
||||
### Session Heartbeat
|
||||
|
||||
Each `process-mailbox` job updates `last_activity_at` on the session every time it flushes an email batch to the indexing queue. This prevents the stale session detector from treating an actively processing large mailbox as stuck.
|
||||
|
||||
### Stale Session Detection
|
||||
|
||||
The `schedule-continuous-sync` job runs `SyncSessionService.cleanStaleSessions()` on every tick. A session is considered stale when `last_activity_at` has not been updated for 30 minutes, indicating the worker that created it has crashed before all mailbox jobs were enqueued.
|
||||
|
||||
When a stale session is detected:
|
||||
|
||||
1. The associated ingestion source is set to `status: 'error'` with a descriptive message.
|
||||
2. The session row is deleted.
|
||||
3. On the next scheduler tick, the source is picked up as an `error` source and a new `continuous-sync` job is dispatched.
|
||||
|
||||
Already-ingested emails from the partial sync are preserved. The next sync skips them via duplicate detection (`checkDuplicate()`).
|
||||
|
||||
## Configuration
|
||||
|
||||
| Environment Variable | Default | Description |
|
||||
| ------------------------------ | ----------- | ----------------------------------------------------- |
|
||||
| `SYNC_FREQUENCY` | `* * * * *` | Cron pattern for continuous sync scheduling |
|
||||
| `INGESTION_WORKER_CONCURRENCY` | `5` | Number of `process-mailbox` jobs that run in parallel |
|
||||
| `MEILI_INDEXING_BATCH` | `500` | Number of emails per `index-email-batch` job |
|
||||
|
||||
### Tuning `INGESTION_WORKER_CONCURRENCY`
|
||||
|
||||
Each `process-mailbox` job holds at most one parsed email in memory at a time during the ingestion loop. At typical email sizes (~50KB average), memory pressure per concurrent job is low. Increase this value on servers with more RAM to process multiple mailboxes in parallel and reduce total sync time.
|
||||
|
||||
### Tuning `MEILI_INDEXING_BATCH`
|
||||
|
||||
Each `index-email-batch` job loads the `.eml` file and all attachments from storage into memory for text extraction before sending to Meilisearch. Reduce this value if the indexing worker experiences memory pressure on deployments with large attachments.
|
||||
|
||||
## Resilience
|
||||
|
||||
- **Job retries:** All jobs are configured with 5 retry attempts using exponential backoff (starting at 1 second). This handles transient API failures from email providers.
|
||||
- **Worker crash recovery:** BullMQ detects stalled jobs (no heartbeat within `lockDuration`) and re-queues them automatically. On retry, already-processed emails are skipped via `checkDuplicate()`.
|
||||
- **Partial sync recovery:** Stale session detection handles the case where a worker crashes mid-dispatch, leaving some mailboxes never enqueued. The source is reset to `error` and the next scheduler tick retries the full sync.
|
||||
96
docs/services/ocr-service.md
Normal file
96
docs/services/ocr-service.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# OCR Service
|
||||
|
||||
The OCR (Optical Character Recognition) and text extraction service is responsible for extracting plain text content from various file formats, such as PDFs, Office documents, and more. This is a crucial component for making email attachments searchable.
|
||||
|
||||
## Overview
|
||||
|
||||
The system employs a two-pronged approach for text extraction:
|
||||
|
||||
1. **Primary Extractor (Apache Tika)**: A powerful and versatile toolkit that can extract text from a wide variety of file formats. It is the recommended method for its superior performance and format support.
|
||||
2. **Legacy Extractor**: A fallback mechanism that uses a combination of libraries (`pdf2json`, `mammoth`, `xlsx`) for common file types like PDF, DOCX, and XLSX. This is used when Apache Tika is not configured.
|
||||
|
||||
The main logic resides in `packages/backend/src/helpers/textExtractor.ts`, which decides which extraction method to use based on the application's configuration.
|
||||
|
||||
## Configuration
|
||||
|
||||
To enable the primary text extraction method, you must configure the URL of an Apache Tika server instance in your environment variables.
|
||||
|
||||
In your `.env` file, set the `TIKA_URL`:
|
||||
|
||||
```env
|
||||
# .env.example
|
||||
|
||||
# Apache Tika Integration
|
||||
# ONLY active if TIKA_URL is set
|
||||
TIKA_URL=http://tika:9998
|
||||
```
|
||||
|
||||
If `TIKA_URL` is not set, the system will automatically fall back to the legacy extraction methods. The service performs a health check on startup to verify connectivity with the Tika server.
|
||||
|
||||
## File Size Limits
|
||||
|
||||
To prevent excessive memory usage and processing time, the service imposes a general size limit on files submitted for text extraction. Files larger than the configured limit will be skipped.
|
||||
|
||||
- **With Apache Tika**: The maximum file size is **100MB**.
|
||||
- **With Legacy Fallback**: The maximum file size is **50MB**.
|
||||
|
||||
## Supported File Formats
|
||||
|
||||
The service's ability to extract text depends on whether it's using Apache Tika or the legacy fallback methods.
|
||||
|
||||
### With Apache Tika
|
||||
|
||||
When `TIKA_URL` is configured, the service can process a vast range of file formats. Apache Tika is designed for broad compatibility and supports hundreds of file types, including but not limited to:
|
||||
|
||||
- Portable Document Format (PDF)
|
||||
- Microsoft Office formats (DOC, DOCX, PPT, PPTX, XLS, XLSX)
|
||||
- OpenDocument Formats (ODT, ODS, ODP)
|
||||
- Rich Text Format (RTF)
|
||||
- Plain Text (TXT, CSV, JSON, XML, HTML)
|
||||
- Image formats with OCR capabilities (PNG, JPEG, TIFF)
|
||||
- Archive formats (ZIP, TAR, GZ)
|
||||
- Email formats (EML, MSG)
|
||||
|
||||
For a complete and up-to-date list, please refer to the official [Apache Tika documentation](https://tika.apache.org/3.2.3/formats.html).
|
||||
|
||||
### With Legacy Fallback
|
||||
|
||||
When Tika is not configured, text extraction is limited to the following formats:
|
||||
|
||||
- `application/pdf` (PDF)
|
||||
- `application/vnd.openxmlformats-officedocument.wordprocessingml.document` (DOCX)
|
||||
- `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` (XLSX)
|
||||
- Plain text formats such as `text/*`, `application/json`, and `application/xml`.
|
||||
|
||||
## Features of the Tika Integration (`OcrService`)
|
||||
|
||||
The `OcrService` (`packages/backend/src/services/OcrService.ts`) provides several enhancements to make text extraction efficient and robust.
|
||||
|
||||
### Caching
|
||||
|
||||
To avoid redundant processing of the same file, the service implements a simple LRU (Least Recently Used) cache.
|
||||
|
||||
- **Cache Key**: A SHA-256 hash of the file's buffer is used as the cache key.
|
||||
- **Functionality**: If a file with the same hash is processed again, the text content is served directly from the cache, saving significant processing time.
|
||||
- **Statistics**: The service keeps track of cache hits, misses, and the hit rate for performance monitoring.
|
||||
|
||||
### Concurrency Management (Semaphore)
|
||||
|
||||
Extracting text from large files can be resource-intensive. To prevent the Tika server from being overwhelmed by multiple requests for the _same file_ simultaneously (e.g., during a large import), a semaphore mechanism is used.
|
||||
|
||||
- **Functionality**: If a request for a specific file (identified by its hash) is already in progress, any subsequent requests for the same file will wait for the first one to complete and then use its result.
|
||||
- **Benefit**: This deduplicates parallel processing efforts and reduces unnecessary load on the Tika server.
|
||||
|
||||
### Health Check and DNS Fallback
|
||||
|
||||
- **Availability Check**: The service includes a `checkTikaAvailability` method to verify that the Tika server is reachable and operational. This check is performed on application startup.
|
||||
- **DNS Fallback**: For convenience in Docker environments, if the Tika URL uses the hostname `tika` (e.g., `http://tika:9998`), the service will automatically attempt a fallback to `localhost` if the initial connection fails.
|
||||
|
||||
## Legacy Fallback Methods
|
||||
|
||||
When Tika is not available, the `extractTextLegacy` function in `textExtractor.ts` handles extraction for a limited set of MIME types:
|
||||
|
||||
- `application/pdf`: Processed using `pdf2json`. Includes a 50MB size limit and a 5-second timeout to prevent memory issues.
|
||||
- `application/vnd.openxmlformats-officedocument.wordprocessingml.document` (DOCX): Processed using `mammoth`.
|
||||
- `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` (XLSX): Processed using `xlsx`.
|
||||
- Plain text formats (`text/*`, `application/json`, `application/xml`): Converted directly from the buffer.
|
||||
@@ -14,8 +14,8 @@ The `StorageService` is configured via environment variables in the `.env` file.
|
||||
|
||||
The `STORAGE_TYPE` variable determines which provider the service will use.
|
||||
|
||||
- `STORAGE_TYPE=local`: Uses the local server's filesystem.
|
||||
- `STORAGE_TYPE=s3`: Uses an S3-compatible object storage service (e.g., AWS S3, MinIO, Google Cloud Storage).
|
||||
- `STORAGE_TYPE=local`: Uses the local server's filesystem.
|
||||
- `STORAGE_TYPE=s3`: Uses an S3-compatible object storage service (e.g., AWS S3, MinIO, Google Cloud Storage).
|
||||
|
||||
### 2. Local Filesystem Configuration
|
||||
|
||||
@@ -27,7 +27,7 @@ STORAGE_TYPE=local
|
||||
STORAGE_LOCAL_ROOT_PATH=/var/data/open-archiver
|
||||
```
|
||||
|
||||
- `STORAGE_LOCAL_ROOT_PATH`: The absolute path on the server where the archive will be created. The service will create subdirectories within this path as needed.
|
||||
- `STORAGE_LOCAL_ROOT_PATH`: The absolute path on the server where the archive will be created. The service will create subdirectories within this path as needed.
|
||||
|
||||
### 3. S3-Compatible Storage Configuration
|
||||
|
||||
@@ -44,12 +44,12 @@ STORAGE_S3_REGION=us-east-1
|
||||
STORAGE_S3_FORCE_PATH_STYLE=true
|
||||
```
|
||||
|
||||
- `STORAGE_S3_ENDPOINT`: The full URL of the S3 API endpoint.
|
||||
- `STORAGE_S3_BUCKET`: The name of the bucket to use for storage.
|
||||
- `STORAGE_S3_ACCESS_KEY_ID`: The access key for your S3 user.
|
||||
- `STORAGE_S3_SECRET_ACCESS_KEY`: The secret key for your S3 user.
|
||||
- `STORAGE_S3_REGION` (Optional): The AWS region of your bucket. Recommended for AWS S3.
|
||||
- `STORAGE_S3_FORCE_PATH_STYLE` (Optional): Set to `true` when using non-AWS S3 services like MinIO.
|
||||
- `STORAGE_S3_ENDPOINT`: The full URL of the S3 API endpoint.
|
||||
- `STORAGE_S3_BUCKET`: The name of the bucket to use for storage.
|
||||
- `STORAGE_S3_ACCESS_KEY_ID`: The access key for your S3 user.
|
||||
- `STORAGE_S3_SECRET_ACCESS_KEY`: The secret key for your S3 user.
|
||||
- `STORAGE_S3_REGION` (Optional): The AWS region of your bucket. Recommended for AWS S3.
|
||||
- `STORAGE_S3_FORCE_PATH_STYLE` (Optional): Set to `true` when using non-AWS S3 services like MinIO.
|
||||
|
||||
## How to Use the Service
|
||||
|
||||
@@ -61,31 +61,27 @@ The `StorageService` is designed to be used via dependency injection in other se
|
||||
import { StorageService } from './StorageService';
|
||||
|
||||
class IngestionService {
|
||||
private storageService: StorageService;
|
||||
private storageService: StorageService;
|
||||
|
||||
constructor() {
|
||||
// The StorageService is instantiated without any arguments.
|
||||
// It automatically reads the configuration from the environment.
|
||||
this.storageService = new StorageService();
|
||||
}
|
||||
constructor() {
|
||||
// The StorageService is instantiated without any arguments.
|
||||
// It automatically reads the configuration from the environment.
|
||||
this.storageService = new StorageService();
|
||||
}
|
||||
|
||||
public async archiveEmail(
|
||||
rawEmail: Buffer,
|
||||
userId: string,
|
||||
messageId: string
|
||||
): Promise<void> {
|
||||
// Define a structured, unique path for the email.
|
||||
const archivePath = `${userId}/messages/${messageId}.eml`;
|
||||
public async archiveEmail(rawEmail: Buffer, userId: string, messageId: string): Promise<void> {
|
||||
// Define a structured, unique path for the email.
|
||||
const archivePath = `${userId}/messages/${messageId}.eml`;
|
||||
|
||||
try {
|
||||
// Use the service. It doesn't know or care if this is writing
|
||||
// to a local disk or an S3 bucket.
|
||||
await this.storageService.put(archivePath, rawEmail);
|
||||
console.log(`Successfully archived email to ${archivePath}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to archive email ${messageId}`, error);
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Use the service. It doesn't know or care if this is writing
|
||||
// to a local disk or an S3 bucket.
|
||||
await this.storageService.put(archivePath, rawEmail);
|
||||
console.log(`Successfully archived email to ${archivePath}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to archive email ${messageId}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -99,9 +95,9 @@ The `StorageService` implements the `IStorageProvider` interface. All methods ar
|
||||
|
||||
Stores a file at the specified path. If a file already exists at that path, it will be overwritten.
|
||||
|
||||
- **`path: string`**: A unique identifier for the file, including its directory structure (e.g., `"user-123/emails/message-abc.eml"`).
|
||||
- **`content: Buffer | NodeJS.ReadableStream`**: The content of the file. It can be a `Buffer` for small files or a `ReadableStream` for large files to ensure memory efficiency.
|
||||
- **Returns**: `Promise<void>` - A promise that resolves when the file has been successfully stored.
|
||||
- **`path: string`**: A unique identifier for the file, including its directory structure (e.g., `"user-123/emails/message-abc.eml"`).
|
||||
- **`content: Buffer | NodeJS.ReadableStream`**: The content of the file. It can be a `Buffer` for small files or a `ReadableStream` for large files to ensure memory efficiency.
|
||||
- **Returns**: `Promise<void>` - A promise that resolves when the file has been successfully stored.
|
||||
|
||||
---
|
||||
|
||||
@@ -109,9 +105,9 @@ Stores a file at the specified path. If a file already exists at that path, it w
|
||||
|
||||
Retrieves a file from the specified path as a readable stream.
|
||||
|
||||
- **`path: string`**: The unique identifier of the file to retrieve.
|
||||
- **Returns**: `Promise<NodeJS.ReadableStream>` - A promise that resolves with a readable stream of the file's content.
|
||||
- **Throws**: An `Error` if the file is not found at the specified path.
|
||||
- **`path: string`**: The unique identifier of the file to retrieve.
|
||||
- **Returns**: `Promise<NodeJS.ReadableStream>` - A promise that resolves with a readable stream of the file's content.
|
||||
- **Throws**: An `Error` if the file is not found at the specified path.
|
||||
|
||||
---
|
||||
|
||||
@@ -119,8 +115,8 @@ Retrieves a file from the specified path as a readable stream.
|
||||
|
||||
Deletes a file from the storage backend.
|
||||
|
||||
- **`path: string`**: The unique identifier of the file to delete.
|
||||
- **Returns**: `Promise<void>` - A promise that resolves when the file is deleted. If the file does not exist, the promise will still resolve successfully without throwing an error.
|
||||
- **`path: string`**: The unique identifier of the file to delete.
|
||||
- **Returns**: `Promise<void>` - A promise that resolves when the file is deleted. If the file does not exist, the promise will still resolve successfully without throwing an error.
|
||||
|
||||
---
|
||||
|
||||
@@ -128,5 +124,5 @@ Deletes a file from the storage backend.
|
||||
|
||||
Checks for the existence of a file.
|
||||
|
||||
- **`path: string`**: The unique identifier of the file to check.
|
||||
- **Returns**: `Promise<boolean>` - A promise that resolves with `true` if the file exists, and `false` otherwise.
|
||||
- **`path: string`**: The unique identifier of the file to check.
|
||||
- **Returns**: `Promise<boolean>` - A promise that resolves with `true` if the file exists, and `false` otherwise.
|
||||
|
||||
44
docs/user-guides/email-providers/eml.md
Normal file
44
docs/user-guides/email-providers/eml.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# EML Import
|
||||
|
||||
OpenArchiver allows you to import EML files from a zip archive. This is useful for importing emails from a variety of sources, including other email clients and services.
|
||||
|
||||
## Preparing the Zip File
|
||||
|
||||
To ensure a successful import, you should compress your .eml files to one zip file according to the following guidelines:
|
||||
|
||||
- **Structure:** The zip file can contain any number of `.eml` files, organized in any folder structure. The folder structure will be preserved in OpenArchiver, so you can use it to organize your emails.
|
||||
- **Compression:** The zip file should be compressed using standard zip compression.
|
||||
|
||||
Here's an example of a valid folder structure:
|
||||
|
||||
```
|
||||
archive.zip
|
||||
├── inbox
|
||||
│ ├── email-01.eml
|
||||
│ └── email-02.eml
|
||||
├── sent
|
||||
│ └── email-03.eml
|
||||
└── drafts
|
||||
├── nested-folder
|
||||
│ └── email-04.eml
|
||||
└── email-05.eml
|
||||
```
|
||||
|
||||
## Creating an EML Ingestion Source
|
||||
|
||||
1. Go to the **Ingestion Sources** page in the OpenArchiver dashboard.
|
||||
2. Click the **Create New** button.
|
||||
3. Select **EML Import** as the provider.
|
||||
4. Enter a name for the ingestion source.
|
||||
5. **Choose Import Method:**
|
||||
- **Upload File:** Click **Choose File** and select the zip archive containing your EML files. (Best for smaller archives)
|
||||
- **Local Path:** Enter the path to the zip file **inside the container**. (Best for large archives)
|
||||
|
||||
> **Note on Local Path:** When using Docker, the "Local Path" is relative to the container's filesystem.
|
||||
>
|
||||
> - **Recommended:** Place your zip file in a `temp` folder inside your configured storage directory (`STORAGE_LOCAL_ROOT_PATH`). This path is already mounted. For example, if your storage path is `/data`, put the file in `/data/temp/emails.zip` and enter `/data/temp/emails.zip` as the path.
|
||||
> - **Alternative:** Mount a separate volume in `docker-compose.yml` (e.g., `- /host/path:/container/path`) and use the container path.
|
||||
|
||||
6. Click the **Submit** button.
|
||||
|
||||
OpenArchiver will then start importing the EML files from the zip archive. The ingestion process may take some time, depending on the size of the archive.
|
||||
124
docs/user-guides/email-providers/google-workspace.md
Normal file
124
docs/user-guides/email-providers/google-workspace.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Connecting to Google Workspace
|
||||
|
||||
This guide provides instructions for Google Workspace administrators to set up a connection that allows the archiving of all user mailboxes within their organization.
|
||||
|
||||
The connection uses a **Google Cloud Service Account** with **Domain-Wide Delegation**. This is a secure method that grants the archiving service permission to access user data on behalf of the administrator, without requiring individual user passwords or consent.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- You must have **Super Administrator** privileges in your Google Workspace account.
|
||||
- You must have access to the **Google Cloud Console** associated with your organization.
|
||||
|
||||
## Setup Overview
|
||||
|
||||
The setup process involves three main parts:
|
||||
|
||||
1. Configuring the necessary permissions in the Google Cloud Console.
|
||||
2. Authorizing the service account in the Google Workspace Admin Console.
|
||||
3. Entering the generated credentials into the OpenArchiver application.
|
||||
|
||||
---
|
||||
|
||||
### Part 1: Google Cloud Console Setup
|
||||
|
||||
In this part, you will create a service account and enable the APIs it needs to function.
|
||||
|
||||
1. **Create a Google Cloud Project:**
|
||||
- Go to the [Google Cloud Console](https://console.cloud.google.com/).
|
||||
- If you don't already have one, create a new project for the archiving service (e.g., "Email Archiver").
|
||||
|
||||
2. **Enable Required APIs:**
|
||||
- In your selected project, navigate to the **"APIs & Services" > "Library"** section.
|
||||
- Search for and enable the following two APIs:
|
||||
- **Gmail API**
|
||||
- **Admin SDK API**
|
||||
|
||||
3. **Create a Service Account:**
|
||||
- Navigate to **"IAM & Admin" > "Service Accounts"**.
|
||||
- Click **"Create Service Account"**.
|
||||
- Give the service account a name (e.g., `email-archiver-service`) and a description.
|
||||
- Click **"Create and Continue"**. You do not need to grant this service account any roles on the project. Click **"Done"**.
|
||||
|
||||
4. **Generate a JSON Key:**
|
||||
- Find the service account you just created in the list.
|
||||
- Click the three-dot menu under **"Actions"** and select **"Manage keys"**.
|
||||
- Click **"Add Key"** > **"Create new key"**.
|
||||
- Select **JSON** as the key type and click **"Create"**.
|
||||
- A JSON file will be downloaded to your computer. **Keep this file secure, as it contains private credentials.** You will need the contents of this file in Part 3.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
#### Error: "iam.disableServiceAccountKeyCreation"
|
||||
|
||||
If you receive an error message stating `The organization policy constraint 'iam.disableServiceAccountKeyCreation' is enforced` when trying to create a JSON key, it means your Google Cloud organization has a policy preventing the creation of new service account keys.
|
||||
|
||||
To resolve this, you must have **Organization Administrator** permissions.
|
||||
|
||||
1. **Navigate to your Organization:** In the Google Cloud Console, use the project selector at the top of the page to select your organization node (it usually has a building icon).
|
||||
2. **Go to IAM:** From the navigation menu, select **"IAM & Admin" > "IAM"**.
|
||||
3. **Edit Your Permissions:** Find your user account in the list and click the pencil icon to edit roles. Add the following two roles:
|
||||
- `Organization Policy Administrator`
|
||||
- `Organization Administrator`
|
||||
_Note: These roles are only available at the organization level, not the project level._
|
||||
4. **Modify the Policy:**
|
||||
- Navigate to **"IAM & Admin" > "Organization Policies"**.
|
||||
- In the filter box, search for the policy **"iam.disableServiceAccountKeyCreation"**.
|
||||
- Click on the policy to edit it.
|
||||
- You can either disable the policy entirely (if your security rules permit) or add a rule to exclude the specific project you are using for the archiver from this policy.
|
||||
5. **Retry Key Creation:** Once the policy is updated, return to your project and you should be able to generate the JSON key as described in Part 1.
|
||||
|
||||
---
|
||||
|
||||
### Part 2: Grant Domain-Wide Delegation
|
||||
|
||||
Now, you will authorize the service account you created to access data from your Google Workspace.
|
||||
|
||||
1. **Get the Service Account's Client ID:**
|
||||
- Go back to the list of service accounts in the Google Cloud Console.
|
||||
- Click on the service account you created.
|
||||
- Under the **"Details"** tab, find and copy the **Unique ID** (this is the Client ID).
|
||||
|
||||
2. **Authorize the Client in Google Workspace:**
|
||||
- Go to your **Google Workspace Admin Console** at [admin.google.com](https://admin.google.com).
|
||||
- Navigate to **Security > Access and data control > API controls**.
|
||||
- Under the "Domain-wide Delegation" section, click **"Manage Domain-wide Delegation"**.
|
||||
- Click **"Add new"**.
|
||||
|
||||
3. **Enter Client Details and Scopes:**
|
||||
- In the **Client ID** field, paste the **Unique ID** you copied from the service account.
|
||||
- In the **OAuth scopes** field, paste the following two scopes exactly as they appear, separated by a comma:
|
||||
```
|
||||
https://www.googleapis.com/auth/admin.directory.user.readonly,https://www.googleapis.com/auth/gmail.readonly
|
||||
```
|
||||
- Click **"Authorize"**.
|
||||
|
||||
The service account is now permitted to list users and read their email data across your domain.
|
||||
|
||||
---
|
||||
|
||||
### Part 3: Connecting in OpenArchiver
|
||||
|
||||
Finally, you will provide the generated credentials to the application.
|
||||
|
||||
1. **Navigate to Ingestion Sources:**
|
||||
From the main dashboard, go to the **Ingestion Sources** page.
|
||||
|
||||
2. **Create a New Source:**
|
||||
Click the **"Create New"** button.
|
||||
|
||||
3. **Fill in the Configuration Details:**
|
||||
- **Name:** Give the source a name (e.g., "Google Workspace Archive").
|
||||
- **Provider:** Select **"Google Workspace"** from the dropdown.
|
||||
- **Service Account Key (JSON):** Open the JSON file you downloaded in Part 1. Copy the entire content of the file and paste it into this text area.
|
||||
- **Impersonated Admin Email:** Enter the email address of a Super Administrator in your Google Workspace (e.g., `admin@your-domain.com`). The service will use this user's authority to discover all other users.
|
||||
|
||||
4. **Save Changes:**
|
||||
Click **"Save changes"**.
|
||||
|
||||
## What Happens Next?
|
||||
|
||||
Once the connection is saved and verified, the system will begin the archiving process:
|
||||
|
||||
1. **User Discovery:** The service will first connect to the Admin SDK to get a list of all active users in your Google Workspace.
|
||||
2. **Initial Import:** The system will then start a background job to import the mailboxes of all discovered users. The status will show as **"Importing"**. This can take a significant amount of time depending on the number of users and the size of their mailboxes.
|
||||
3. **Continuous Sync:** After the initial import is complete, the status will change to **"Active"**. The system will then periodically check each user's mailbox for new emails and archive them automatically.
|
||||
66
docs/user-guides/email-providers/imap.md
Normal file
66
docs/user-guides/email-providers/imap.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Connecting to a Generic IMAP Server
|
||||
|
||||
This guide will walk you through connecting a standard IMAP email account as an ingestion source. This allows you to archive emails from any provider that supports the IMAP protocol, which is common for many self-hosted or traditional email services.
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
1. **Navigate to Ingestion Sources:**
|
||||
From the main dashboard, go to the **Ingestions** page.
|
||||
|
||||
2. **Create a New Source:**
|
||||
Click the **"Create New"** button to open the ingestion source configuration dialog.
|
||||
|
||||
3. **Fill in the Configuration Details:**
|
||||
You will see a form with several fields. Here is how to fill them out for an IMAP connection:
|
||||
- **Name:** Give your ingestion source a descriptive name that you will easily recognize, such as "Work Email (IMAP)" or "Personal Gmail".
|
||||
|
||||
- **Provider:** From the dropdown menu, select **"Generic IMAP"**. This will reveal the specific fields required for an IMAP connection.
|
||||
|
||||
- **Host:** Enter the server address for your email provider's IMAP service. This often looks like `imap.your-provider.com` or `mail.your-domain.com`.
|
||||
|
||||
- **Port:** Enter the port number for the IMAP server. For a secure connection (which is strongly recommended), this is typically `993`.
|
||||
|
||||
- **Username:** Enter the full email address or username you use to log in to your email account.
|
||||
|
||||
- **Password:** Enter the password for your email account.
|
||||
|
||||
4. **Save Changes:**
|
||||
Once you have filled in all the details, click the **"Save changes"** button.
|
||||
|
||||
## Security Recommendation: Use an App Password
|
||||
|
||||
For enhanced security, we strongly recommend using an **"app password"** (sometimes called an "app-specific password") instead of your main account password.
|
||||
|
||||
Many email providers (like Gmail, Outlook, and Fastmail) allow you to generate a unique password that grants access only to a specific application (in this case, the archiving service). If you ever need to revoke access, you can simply delete the app password without affecting your main account login.
|
||||
|
||||
Please consult your email provider's documentation to see if they support app passwords and how to create one.
|
||||
|
||||
### How to Obtain an App Password for Gmail
|
||||
|
||||
1. **Enable 2-Step Verification:** You must have 2-Step Verification turned on for your Google Account.
|
||||
2. **Go to App Passwords:** Visit [myaccount.google.com/apppasswords](https://myaccount.google.com/apppasswords). You may be asked to sign in again.
|
||||
3. **Create the Password:**
|
||||
- At the bottom, click **"Select app"** and choose **"Other (Custom name)"**.
|
||||
- Give it a name you'll recognize, like "OpenArchiver".
|
||||
- Click **"Generate"**.
|
||||
4. **Use the Password:** A 16-digit password will be displayed. Copy this password and paste it into the **Password** field in the OpenArchiver ingestion source form.
|
||||
|
||||
### How to Obtain an App Password for Outlook/Microsoft Accounts
|
||||
|
||||
1. **Enable Two-Step Verification:** You must have two-step verification enabled for your Microsoft account.
|
||||
2. **Go to Security Options:** Sign in to your Microsoft account and navigate to the [Advanced security options](https://account.live.com/proofs/manage/additional).
|
||||
3. **Create a New App Password:**
|
||||
- Scroll down to the **"App passwords"** section.
|
||||
- Click **"Create a new app password"**.
|
||||
4. **Use the Password:** A new password will be generated. Use this password in the **Password** field in the OpenArchiver ingestion source form.
|
||||
|
||||
## What Happens Next?
|
||||
|
||||
After you save the connection, the system will attempt to connect to the IMAP server. The status of the ingestion source will update to reflect its current state:
|
||||
|
||||
- **Importing:** The system is performing the initial, one-time import of all emails from your `INBOX`. This may take a while depending on the size of your mailbox.
|
||||
- **Active:** The initial import is complete, and the system will now periodically check for and archive new emails.
|
||||
- **Paused:** The connection is valid, but the system will not check for new emails until you resume it.
|
||||
- **Error:** The system was unable to connect using the provided credentials. Please double-check your Host, Port, Username, and Password and try again.
|
||||
|
||||
You can view, edit, pause, or manually sync any of your ingestion sources from the main table on the **Ingestions** page.
|
||||
13
docs/user-guides/email-providers/index.md
Normal file
13
docs/user-guides/email-providers/index.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Connecting Email Providers
|
||||
|
||||
Open Archiver can connect to a variety of email sources to ingest and archive your emails. This section provides guides for connecting to popular email providers.
|
||||
|
||||
Choose your provider from the list below to get started:
|
||||
|
||||
- [Google Workspace](./google-workspace.md)
|
||||
- [Microsoft 365](./microsoft-365.md)
|
||||
- [Generic IMAP Server](./imap.md)
|
||||
- [EML Import](./eml.md)
|
||||
- [PST Import](./pst.md)
|
||||
- [Mbox Import](./mbox.md)
|
||||
- [Merging Ingestion Sources](./merging-sources.md)
|
||||
36
docs/user-guides/email-providers/mbox.md
Normal file
36
docs/user-guides/email-providers/mbox.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Mbox Ingestion
|
||||
|
||||
Mbox is a common format for storing email messages. This guide will walk you through the process of ingesting mbox files into OpenArchiver.
|
||||
|
||||
## 1. Exporting from Your Email Client
|
||||
|
||||
Most email clients that support mbox exports will allow you to export a folder of emails as a single `.mbox` file. Here are the general steps:
|
||||
|
||||
- **Mozilla Thunderbird**: Right-click on a folder, select **ImportExportTools NG**, and then choose **Export folder**.
|
||||
- **Gmail**: You can use Google Takeout to export your emails in mbox format.
|
||||
- **Other Clients**: Refer to your email client's documentation for instructions on how to export emails to an mbox file.
|
||||
|
||||
## 2. Uploading to OpenArchiver
|
||||
|
||||
Once you have your `.mbox` file, you can upload it to OpenArchiver through the web interface.
|
||||
|
||||
1. Navigate to the **Ingestion** page.
|
||||
2. Click on the **New Ingestion** button.
|
||||
3. Select **Mbox** as the source type.
|
||||
4. **Choose Import Method:**
|
||||
- **Upload File:** Upload your `.mbox` file.
|
||||
- **Local Path:** Enter the path to the mbox file **inside the container**.
|
||||
|
||||
> **Note on Local Path:** When using Docker, the "Local Path" is relative to the container's filesystem.
|
||||
>
|
||||
> - **Recommended:** Place your mbox file in a `temp` folder inside your configured storage directory (`STORAGE_LOCAL_ROOT_PATH`). This path is already mounted. For example, if your storage path is `/data`, put the file in `/data/temp/emails.mbox` and enter `/data/temp/emails.mbox` as the path.
|
||||
> - **Alternative:** Mount a separate volume in `docker-compose.yml` (e.g., `- /host/path:/container/path`) and use the container path.
|
||||
|
||||
## 3. Folder Structure
|
||||
|
||||
OpenArchiver will attempt to preserve the original folder structure of your emails. This is done by inspecting the following email headers:
|
||||
|
||||
- `X-Gmail-Labels`: Used by Gmail to store labels.
|
||||
- `X-Folder`: A custom header used by some email clients like Thunderbird.
|
||||
|
||||
If neither of these headers is present, the emails will be ingested into the root of the archive.
|
||||
105
docs/user-guides/email-providers/merging-sources.md
Normal file
105
docs/user-guides/email-providers/merging-sources.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Merging Ingestion Sources
|
||||
|
||||
Merged ingestion groups let you combine multiple ingestion sources so that their emails appear unified in browsing, search, and thread views. This is useful when you want to pair a historical archive (for example, a PST or Mbox import) with a live connection, or when migrating between providers.
|
||||
|
||||
## Concepts
|
||||
|
||||
| Term | Definition |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Root source** | An ingestion source where no merge parent is set. Shown as the primary row in the Ingestions table. All emails in the group are physically owned by the root. |
|
||||
| **Child source** | An ingestion source merged into a root. Acts as a fetch assistant — it connects to the provider and retrieves emails, but all data is stored under the root source. |
|
||||
| **Group** | A root source and all its children. All emails from every member are stored under and owned by the root. |
|
||||
|
||||
The hierarchy is **flat** — only one level of nesting is supported. If you merge a source into a child, the system automatically redirects the relationship to the root.
|
||||
|
||||
## Root Ownership — How Storage and Data Work
|
||||
|
||||
This is the key design principle of merged sources:
|
||||
|
||||
> **Child sources are assistants. They fetch emails from their provider but never own any stored data. Every email ingested by a child is written to the root source's storage folder and assigned the root source's ID in the database.**
|
||||
|
||||
In practical terms:
|
||||
|
||||
- The storage path for every email belongs to the root: `openarchiver/{root-name}-{root-id}/emails/...`
|
||||
- Every `archived_emails` database row created by a child ingestion will have `ingestionSourceId` set to the **root's ID**, not the child's.
|
||||
- Attachments are also stored under the root's folder and scoped to the root's ID.
|
||||
- The root's **Preserve Original File** (GoBD compliance) setting is inherited by all children in the group. A child's own `preserveOriginalFile` setting is ignored during ingestion — only the root's setting applies.
|
||||
|
||||
This means browsing the root source's emails will show all emails from the entire group, including those fetched by child sources, without any extra configuration.
|
||||
|
||||
## When to Use Merged Sources
|
||||
|
||||
- **Historical + live**: Import a PST archive and merge it into an active IMAP or Google Workspace connection so historical and current emails appear in one unified mailbox.
|
||||
- **Provider migration**: Add a new Microsoft 365 connector and merge it with your existing Google Workspace connector during a cutover period.
|
||||
- **Backfill**: Import an Mbox export and merge it with a live connection to cover a gap in the archive.
|
||||
|
||||
## How to Merge a New Source Into an Existing One
|
||||
|
||||
Merging can only be configured **at creation time**.
|
||||
|
||||
1. Navigate to the **Ingestions** page.
|
||||
2. Click **Create New** to open the ingestion source form.
|
||||
3. Fill in the provider details as usual.
|
||||
4. Expand the **Advanced Options** section at the bottom of the form. This section is only visible when at least one ingestion source already exists.
|
||||
5. Check **Merge into existing ingestion** and select the target root source from the dropdown.
|
||||
6. Click **Submit**.
|
||||
|
||||
The new source will run its initial import normally. Once complete, its emails will appear alongside those of the root source — all stored under the root.
|
||||
|
||||
## How Emails Appear When Merged
|
||||
|
||||
When you browse archived emails for a root source, you see all emails in the group because they are all physically owned by the root. There is nothing to aggregate — the data is already unified at the storage and database level.
|
||||
|
||||
The same applies to search: filtering by a root source ID returns all emails in the group.
|
||||
|
||||
Threads also span the merge group. If a reply arrived via a different source than the original message, it still appears in the correct thread.
|
||||
|
||||
## How Syncing Works
|
||||
|
||||
Each source syncs **independently**. The scheduler picks up all sources with status `active` or `error`, regardless of whether they are merged.
|
||||
|
||||
- File-based imports (PST, EML, Mbox) finish with status `imported` and are never re-synced automatically.
|
||||
- Live sources (IMAP, Google Workspace, Microsoft 365) continue their normal sync cycle.
|
||||
|
||||
When you trigger **Force Sync** on a root source, the system also queues a sync for all non-file-based children that are currently `active` or `error`.
|
||||
|
||||
## Deduplication Across the Group
|
||||
|
||||
When ingesting emails, duplicate detection covers the **entire merge group**. If the same email (matched by its RFC `Message-ID` header or provider-specific ID) already exists anywhere in the group, it is skipped and not stored again.
|
||||
|
||||
## Preserve Original File (GoBD Compliance) and Merged Sources
|
||||
|
||||
The **Preserve Original File** setting on the root source governs the entire group. When this setting is enabled on the root:
|
||||
|
||||
- All emails ingested by child sources are also stored unmodified (raw EML, no attachment stripping).
|
||||
- The child's own `preserveOriginalFile` setting has no effect — the root's setting is always used.
|
||||
|
||||
This ensures consistent compliance behaviour across the group. If you require GoBD or SEC 17a-4 compliance for an entire merged group, enable **Preserve Original File** on the root source before adding any children.
|
||||
|
||||
## Editing Sources in a Group
|
||||
|
||||
Each source in a group can be edited independently. Expand the group row in the Ingestions table by clicking the chevron, then use the **⋮** actions menu on the specific source (root or child) you want to edit.
|
||||
|
||||
## Unmerging a Child Source
|
||||
|
||||
To detach a child from its group and make it standalone:
|
||||
|
||||
1. Expand the group row by clicking the chevron next to the root source name.
|
||||
2. Open the **⋮** actions menu on the child source.
|
||||
3. Click **Unmerge**.
|
||||
|
||||
The child becomes an independent root source. No email data is moved or deleted.
|
||||
|
||||
> **Note:** Because all emails fetched by the child were stored under the root source's ID, unmerging the child does not transfer those emails. Historical emails ingested while the source was a child remain owned by the root. Only new emails ingested after unmerging will be stored under the (now standalone) child.
|
||||
|
||||
## Deleting Sources in a Group
|
||||
|
||||
- **Deleting a root source** also deletes all its children: their configuration, and all emails, attachments, storage files, and search index entries owned by the root are all removed. Because all group emails are stored under the root, this effectively removes the entire group's archive.
|
||||
- **Deleting a child source** removes only the child's configuration and sync state. Emails already ingested by the child are stored under the root and are **not** deleted.
|
||||
|
||||
A warning is shown in the delete confirmation dialog when a root source has children.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **Merging existing standalone sources is not supported.** You can only merge a source into a group at creation time. To merge two existing sources, you must delete one and recreate it with the merge target selected.
|
||||
- **Historical data from a child source before unmerging remains with the root.** If you unmerge a child, emails it previously ingested stay owned by the root and are not migrated to the child.
|
||||
93
docs/user-guides/email-providers/microsoft-365.md
Normal file
93
docs/user-guides/email-providers/microsoft-365.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Connecting to Microsoft 365
|
||||
|
||||
This guide provides instructions for Microsoft 365 administrators to set up a connection that allows the archiving of all user mailboxes within their organization.
|
||||
|
||||
The connection uses the **Microsoft Graph API** and an **App Registration** in Microsoft Entra ID. This is a secure, standard method that grants the archiving service permission to read email data on your behalf without ever needing to handle user passwords.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- You must have one of the following administrator roles in your Microsoft 365 tenant: **Global Administrator**, **Application Administrator**, or **Cloud Application Administrator**.
|
||||
|
||||
## Setup Overview
|
||||
|
||||
The setup process involves four main parts, all performed within the Microsoft Entra admin center and the OpenArchiver application:
|
||||
|
||||
1. Registering a new application identity for the archiver in Entra ID.
|
||||
2. Granting the application the specific permissions it needs to read mail.
|
||||
3. Creating a secure password (a client secret) for the application.
|
||||
4. Entering the generated credentials into the OpenArchiver application.
|
||||
|
||||
---
|
||||
|
||||
### Part 1: Register a New Application in Microsoft Entra ID
|
||||
|
||||
First, you will create an "App registration," which acts as an identity for the archiving service within your Microsoft 365 ecosystem.
|
||||
|
||||
1. Sign in to the [Microsoft Entra admin center](https://entra.microsoft.com).
|
||||
2. In the left-hand navigation pane, go to **Identity > Applications > App registrations**.
|
||||
3. Click the **+ New registration** button at the top of the page.
|
||||
4. On the "Register an application" screen:
|
||||
- **Name:** Give the application a descriptive name you will recognize, such as `OpenArchiver Service`.
|
||||
- **Supported account types:** Select **"Accounts in this organizational directory only (Default Directory only - Single tenant)"**. This is the most secure option.
|
||||
- **Redirect URI (optional):** You can leave this blank.
|
||||
5. Click the **Register** button. You will be taken to the application's main "Overview" page.
|
||||
|
||||
---
|
||||
|
||||
### Part 2: Grant API Permissions
|
||||
|
||||
Next, you must grant the application the specific permissions required to read user profiles and their mailboxes.
|
||||
|
||||
1. From your new application's page, select **API permissions** from the left-hand menu.
|
||||
2. Click the **+ Add a permission** button.
|
||||
3. In the "Request API permissions" pane, select **Microsoft Graph**.
|
||||
4. Select **Application permissions**. This is critical as it allows the service to run in the background without a user being signed in.
|
||||
5. In the "Select permissions" search box, find and check the boxes for the following two permissions:
|
||||
- `Mail.Read`
|
||||
- `User.Read.All`
|
||||
6. Click the **Add permissions** button at the bottom.
|
||||
7. **Crucial Final Step:** You will now see the permissions in your list with a warning status. You must grant consent on behalf of your organization. Click the **"Grant admin consent for [Your Organization's Name]"** button located above the permissions table. Click **Yes** in the confirmation dialog. The status for both permissions should now show a green checkmark.
|
||||
|
||||
---
|
||||
|
||||
### Part 3: Create a Client Secret
|
||||
|
||||
The client secret is a password that the archiving service will use to authenticate. Treat this with the same level of security as an administrator's password.
|
||||
|
||||
1. In your application's menu, navigate to **Certificates & secrets**.
|
||||
2. Select the **Client secrets** tab and click **+ New client secret**.
|
||||
3. In the pane that appears:
|
||||
- **Description:** Enter a clear description, such as `OpenArchiver Key`.
|
||||
- **Expires:** Select an expiry duration. We recommend **12 or 24 months**. Set a calendar reminder to renew it before it expires to prevent service interruption.
|
||||
4. Click **Add**.
|
||||
5. **IMMEDIATELY COPY THE SECRET:** The secret is now visible in the **"Value"** column. This is the only time it will be fully displayed. Copy this value now and store it in a secure password manager before navigating away. If you lose it, you must create a new one.
|
||||
|
||||
---
|
||||
|
||||
### Part 4: Connecting in OpenArchiver
|
||||
|
||||
You now have the three pieces of information required to configure the connection.
|
||||
|
||||
1. **Navigate to Ingestion Sources:**
|
||||
In the OpenArchiver application, go to the **Ingestion Sources** page.
|
||||
|
||||
2. **Create a New Source:**
|
||||
Click the **"Create New"** button.
|
||||
|
||||
3. **Fill in the Configuration Details:**
|
||||
- **Name:** Give the source a name (e.g., "Microsoft 365 Archive").
|
||||
- **Provider:** Select **"Microsoft 365"** from the dropdown.
|
||||
- **Application (Client) ID:** Go to the **Overview** page of your app registration in the Entra admin center and copy this value.
|
||||
- **Directory (Tenant) ID:** This value is also on the **Overview** page.
|
||||
- **Client Secret Value:** Paste the secret **Value** (not the Secret ID) that you copied and saved in the previous step.
|
||||
|
||||
4. **Save Changes:**
|
||||
Click **"Save changes"**.
|
||||
|
||||
## What Happens Next?
|
||||
|
||||
Once the connection is saved, the system will begin the archiving process:
|
||||
|
||||
1. **User Discovery:** The service will connect to the Microsoft Graph API to get a list of all users in your organization.
|
||||
2. **Initial Import:** The system will begin a background job to import the mailboxes of all discovered users, folder by folder. The status will show as **"Importing"**. This can take a significant amount of time.
|
||||
3. **Continuous Sync:** After the initial import, the status will change to **"Active"**. The system will use Microsoft Graph's delta query feature to efficiently fetch only new or changed emails, ensuring the archive stays up-to-date.
|
||||
29
docs/user-guides/email-providers/pst.md
Normal file
29
docs/user-guides/email-providers/pst.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# PST Import
|
||||
|
||||
OpenArchiver allows you to import PST files. This is useful for importing emails from a variety of sources, including Microsoft Outlook.
|
||||
|
||||
## Preparing the PST File
|
||||
|
||||
To ensure a successful import, you should prepare your PST file according to the following guidelines:
|
||||
|
||||
- **Structure:** The PST file can contain any number of emails, organized in any folder structure. The folder structure will be preserved in OpenArchiver, so you can use it to organize your emails.
|
||||
- **Password Protection:** OpenArchiver does not support password-protected PST files. Please remove the password from your PST file before importing it.
|
||||
|
||||
## Creating a PST Ingestion Source
|
||||
|
||||
1. Go to the **Ingestion Sources** page in the OpenArchiver dashboard.
|
||||
2. Click the **Create New** button.
|
||||
3. Select **PST Import** as the provider.
|
||||
4. Enter a name for the ingestion source.
|
||||
5. **Choose Import Method:**
|
||||
- **Upload File:** Click **Choose File** and select the PST file from your computer. (Best for smaller files)
|
||||
- **Local Path:** Enter the path to the PST file **inside the container**. (Best for large files)
|
||||
|
||||
> **Note on Local Path:** When using Docker, the "Local Path" is relative to the container's filesystem.
|
||||
>
|
||||
> - **Recommended:** Place your file in a `temp` folder inside your configured storage directory (`STORAGE_LOCAL_ROOT_PATH`). This path is already mounted. For example, if your storage path is `/data`, put the file in `/data/temp/archive.pst` and enter `/data/temp/archive.pst` as the path.
|
||||
> - **Alternative:** Mount a separate volume in `docker-compose.yml` (e.g., `- /host/path:/container/path`) and use the container path.
|
||||
|
||||
6. Click the **Submit** button.
|
||||
|
||||
OpenArchiver will then start importing the emails from the PST file. The ingestion process may take some time, depending on the size of the file.
|
||||
341
docs/user-guides/installation.md
Normal file
341
docs/user-guides/installation.md
Normal file
@@ -0,0 +1,341 @@
|
||||
# Installation Guide
|
||||
|
||||
This guide will walk you through setting up Open Archiver using Docker Compose. This is the recommended method for deploying the application.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) installed on your server or local machine.
|
||||
- A server or local machine with at least 4GB of RAM (2GB of RAM if you use external Postgres, Redis (Valkey) and Meilisearch instances).
|
||||
- [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) installed on your server or local machine.
|
||||
|
||||
## 1. Clone the Repository
|
||||
|
||||
First, clone the Open Archiver repository to your machine:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/LogicLabs-OU/OpenArchiver.git
|
||||
cd OpenArchiver
|
||||
```
|
||||
|
||||
## 2. Create a Directory for Local Storage (Important)
|
||||
|
||||
Before configuring the application, you **must** create a directory on your host machine where Open Archiver will store its data (such as emails and attachments). Manually creating this directory helps prevent potential permission issues.
|
||||
|
||||
Foe examples, you can use this path `/var/data/open-archiver`.
|
||||
|
||||
Run the following commands to create the directory and set the correct permissions:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/data/open-archiver
|
||||
sudo chown -R $(id -u):$(id -g) /var/data/open-archiver
|
||||
```
|
||||
|
||||
This ensures the directory is owned by your current user, which is necessary for the application to have write access. You will set this path in your `.env` file in the next step.
|
||||
|
||||
## 3. Configure Your Environment
|
||||
|
||||
The application is configured using environment variables. You'll need to create a `.env` file to store your configuration.
|
||||
|
||||
Copy the example environment file for Docker:
|
||||
|
||||
```bash
|
||||
cp .env.example.docker .env
|
||||
```
|
||||
|
||||
Now, open the `.env` file in a text editor and customize the settings.
|
||||
|
||||
### Key Configuration Steps
|
||||
|
||||
1. **Set the Storage Path**: Find the `STORAGE_LOCAL_ROOT_PATH` variable and set it to the path you just created.
|
||||
|
||||
```env
|
||||
STORAGE_LOCAL_ROOT_PATH=/var/data/open-archiver
|
||||
```
|
||||
|
||||
2. **Secure Your Instance**: You must change the following placeholder values to secure your instance:
|
||||
|
||||
- `POSTGRES_PASSWORD`: A strong, unique password for the database.
|
||||
- `REDIS_PASSWORD`: A strong, unique password for the Valkey/Redis service.
|
||||
- `MEILI_MASTER_KEY`: A complex key for Meilisearch.
|
||||
- `JWT_SECRET`: A long, random string for signing authentication tokens.
|
||||
- `ENCRYPTION_KEY`: A 32-byte hex string for encrypting sensitive data in the database. You can generate one with the following command:
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
- `STORAGE_ENCRYPTION_KEY`: **(Optional but Recommended)** A 32-byte hex string for encrypting emails and attachments at rest. If this key is not provided, storage encryption will be disabled. You can generate one with:
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
### Storage Configuration
|
||||
|
||||
By default, the Docker Compose setup uses local filesystem storage, which is persisted using a Docker volume named `archiver-data`. This is suitable for most use cases.
|
||||
|
||||
If you want to use S3-compatible object storage, change the `STORAGE_TYPE` to `s3` and fill in your S3 credentials (`STORAGE_S3_*` variables). When `STORAGE_TYPE` is set to `local`, the S3-related variables are not required.
|
||||
|
||||
### Using External Services
|
||||
|
||||
For convenience, the `docker-compose.yml` file includes services for PostgreSQL, Valkey (Redis), and Meilisearch. However, you can use your own external or managed instances for these services.
|
||||
|
||||
To do so:
|
||||
|
||||
1. **Update your `.env` file**: Change the host, port, and credential variables to point to your external service instances. For example, you would update `DATABASE_URL`, `REDIS_HOST`, and `MEILI_HOST`.
|
||||
2. **Modify `docker-compose.yml`**: Remove or comment out the service definitions for `postgres`, `valkey`, and `meilisearch` from your `docker-compose.yml` file.
|
||||
|
||||
This will configure the Open Archiver application to connect to your services instead of starting the default ones.
|
||||
|
||||
### Environment Variable Reference
|
||||
|
||||
Here is a complete list of environment variables available for configuration:
|
||||
|
||||
#### Application Settings
|
||||
|
||||
| Variable | Description | Default Value |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------- |
|
||||
| `NODE_ENV` | The application environment. | `development` |
|
||||
| `PORT_BACKEND` | The port for the backend service. | `4000` |
|
||||
| `PORT_FRONTEND` | The port for the frontend service. | `3000` |
|
||||
| `APP_URL` | The public-facing URL of your application. This is used by the backend to configure CORS. | `http://localhost:3000` |
|
||||
| `ORIGIN` | Used by the SvelteKit Node adapter to determine the server's public-facing URL. It should always be set to the value of `APP_URL` (e.g., `ORIGIN=$APP_URL`). | `http://localhost:3000` |
|
||||
| `SYNC_FREQUENCY` | The frequency of continuous email syncing. See [cron syntax](https://crontab.guru/) for more details. | `* * * * *` |
|
||||
| `ALL_INCLUSIVE_ARCHIVE` | Set to `true` to include all emails, including Junk and Trash folders, in the email archive. | `false` |
|
||||
|
||||
#### Docker Compose Service Configuration
|
||||
|
||||
These variables are used by `docker-compose.yml` to configure the services.
|
||||
|
||||
| Variable | Description | Default Value |
|
||||
| ---------------------- | ---------------------------------------------------- | -------------------------------------------------------- |
|
||||
| `POSTGRES_DB` | The name of the PostgreSQL database. | `open_archive` |
|
||||
| `POSTGRES_USER` | The username for the PostgreSQL database. | `admin` |
|
||||
| `POSTGRES_PASSWORD` | The password for the PostgreSQL database. | `password` |
|
||||
| `DATABASE_URL` | The connection URL for the PostgreSQL database. | `postgresql://admin:password@postgres:5432/open_archive` |
|
||||
| `MEILI_MASTER_KEY` | The master key for Meilisearch. | `aSampleMasterKey` |
|
||||
| `MEILI_HOST` | The host for the Meilisearch service. | `http://meilisearch:7700` |
|
||||
| `MEILI_INDEXING_BATCH` | The number of emails to batch together for indexing. | `500` |
|
||||
| `REDIS_HOST` | The host for the Valkey (Redis) service. | `valkey` |
|
||||
| `REDIS_PORT` | The port for the Valkey (Redis) service. | `6379` |
|
||||
| `REDIS_USER` | Optional Redis username if ACLs are used. | |
|
||||
| `REDIS_PASSWORD` | The password for the Valkey (Redis) service. | `defaultredispassword` |
|
||||
| `REDIS_TLS_ENABLED` | Enable or disable TLS for Redis. | `false` |
|
||||
|
||||
#### Storage Settings
|
||||
|
||||
| Variable | Description | Default Value |
|
||||
| ------------------------------ | ----------------------------------------------------------------------------------------------------------- | ------------------------- |
|
||||
| `STORAGE_TYPE` | The storage backend to use (`local` or `s3`). | `local` |
|
||||
| `BODY_SIZE_LIMIT` | The maximum request body size for uploads. Can be a number in bytes or a string with a unit (e.g., `100M`). | `100M` |
|
||||
| `STORAGE_LOCAL_ROOT_PATH` | The root path for Open Archiver app data. | `/var/data/open-archiver` |
|
||||
| `STORAGE_S3_ENDPOINT` | The endpoint for S3-compatible storage (required if `STORAGE_TYPE` is `s3`). | |
|
||||
| `STORAGE_S3_BUCKET` | The bucket name for S3-compatible storage (required if `STORAGE_TYPE` is `s3`). | |
|
||||
| `STORAGE_S3_ACCESS_KEY_ID` | The access key ID for S3-compatible storage (required if `STORAGE_TYPE` is `s3`). | |
|
||||
| `STORAGE_S3_SECRET_ACCESS_KEY` | The secret access key for S3-compatible storage (required if `STORAGE_TYPE` is `s3`). | |
|
||||
| `STORAGE_S3_REGION` | The region for S3-compatible storage (required if `STORAGE_TYPE` is `s3`). | |
|
||||
| `STORAGE_S3_FORCE_PATH_STYLE` | Force path-style addressing for S3 (optional). | `false` |
|
||||
| `STORAGE_ENCRYPTION_KEY` | A 32-byte hex string for AES-256 encryption of files at rest. If not set, files will not be encrypted. | |
|
||||
|
||||
#### Security & Authentication
|
||||
|
||||
| Variable | Description | Default Value |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
|
||||
| `ENABLE_DELETION` | Enable or disable deletion of emails and ingestion sources. If this option is not set, or is set to any value other than `true`, deletion will be disabled for the entire instance. | `false` |
|
||||
| `JWT_SECRET` | A secret key for signing JWT tokens. | `a-very-secret-key-that-you-should-change` |
|
||||
| `JWT_EXPIRES_IN` | The expiration time for JWT tokens. | `7d` |
|
||||
| ~~`SUPER_API_KEY`~~ (Deprecated) | An API key with super admin privileges. (The SUPER_API_KEY is deprecated since v0.3.0 after we roll out the role-based access control system.) | |
|
||||
| `RATE_LIMIT_WINDOW_MS` | The window in milliseconds for which API requests are checked. | `900000` (15 minutes) |
|
||||
| `RATE_LIMIT_MAX_REQUESTS` | The maximum number of API requests allowed from an IP within the window. | `100` |
|
||||
| `ENCRYPTION_KEY` | A 32-byte hex string for encrypting sensitive data in the database. | |
|
||||
|
||||
#### Apache Tika Integration
|
||||
|
||||
| Variable | Description | Default Value |
|
||||
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------ |
|
||||
| `TIKA_URL` | Optional. The URL of an Apache Tika server for advanced text extraction from attachments. If not set, the application falls back to built-in parsers for PDF, Word, and Excel files. | `http://tika:9998` |
|
||||
|
||||
## 4. Run the Application
|
||||
|
||||
Once you have configured your `.env` file, you can start all the services using Docker Compose:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This command will:
|
||||
|
||||
- Pull the required Docker images for the frontend, backend, database, and other services.
|
||||
- Create and start the containers in the background (`-d` flag).
|
||||
- Create the persistent volumes for your data.
|
||||
|
||||
You can check the status of the running containers with:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
## 5. Access the Application
|
||||
|
||||
Once the services are running, you can access the Open Archiver web interface by navigating to `http://localhost:3000` in your web browser.
|
||||
|
||||
Upon first visit, you will be redirected to the `/setup` page where you can set up your admin account. Make sure you are the first person who accesses the instance.
|
||||
|
||||
If you are not redirected to the `/setup` page but instead see the login page, there might be something wrong with the database. Restart the service and try again.
|
||||
|
||||
## 6. Next Steps
|
||||
|
||||
After successfully deploying and logging into Open Archiver, the next step is to configure your ingestion sources to start archiving emails.
|
||||
|
||||
- [Connecting to Google Workspace](./email-providers/google-workspace.md)
|
||||
- [Connecting to Microsoft 365](./email-providers/microsoft-365.md)
|
||||
- [Connecting to a Generic IMAP Server](./email-providers/imap.md)
|
||||
|
||||
## Updating Your Installation
|
||||
|
||||
To update your Open Archiver instance to the latest version, run the following commands:
|
||||
|
||||
```bash
|
||||
# Pull the latest changes from the repository
|
||||
git pull
|
||||
|
||||
# Pull the latest Docker images
|
||||
docker compose pull
|
||||
|
||||
# Restart the services with the new images
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Deploying on Coolify
|
||||
|
||||
If you are deploying Open Archiver on [Coolify](https://coolify.io/), it is recommended to let Coolify manage the Docker networks for you. This can help avoid potential routing conflicts and simplify your setup.
|
||||
|
||||
To do this, you will need to make a small modification to your `docker-compose.yml` file.
|
||||
|
||||
### Modify `docker-compose.yml` for Coolify
|
||||
|
||||
1. **Open your `docker-compose.yml` file** in a text editor.
|
||||
|
||||
2. **Remove all `networks` sections** from the file. This includes the network configuration for each service and the top-level network definition.
|
||||
|
||||
Specifically, you need to remove:
|
||||
- The `networks: - open-archiver-net` lines from the `open-archiver`, `postgres`, `valkey`, and `meilisearch` services.
|
||||
- The entire `networks:` block at the end of the file.
|
||||
|
||||
Here is an example of what to remove from a service:
|
||||
|
||||
```diff
|
||||
services:
|
||||
open-archiver:
|
||||
image: logiclabshq/open-archiver:latest
|
||||
# ... other settings
|
||||
- networks:
|
||||
- - open-archiver-net
|
||||
```
|
||||
|
||||
And remove this entire block from the end of the file:
|
||||
|
||||
```diff
|
||||
- networks:
|
||||
- open-archiver-net:
|
||||
- driver: bridge
|
||||
```
|
||||
|
||||
3. **Save the modified `docker-compose.yml` file.**
|
||||
|
||||
By removing these sections, you allow Coolify to automatically create and manage the necessary networks, ensuring that all services can communicate with each other and are correctly exposed through Coolify's reverse proxy.
|
||||
|
||||
After making these changes, you can proceed with deploying your application on Coolify as you normally would.
|
||||
|
||||
## Where is my data stored (When using local storage and Docker)?
|
||||
|
||||
If you are using local storage to store your emails, based on your `docker-compose.yml` file, your data is being stored in what's called a "named volume" (`archiver-data`). That's why you're not seeing the files in the `./data/open-archiver` directory you created.
|
||||
|
||||
1. **List all Docker volumes**:
|
||||
|
||||
Run this command to see all the volumes on your system:
|
||||
|
||||
```bash
|
||||
docker volume ls
|
||||
```
|
||||
|
||||
2. **Identify the correct volume**:
|
||||
|
||||
Look through the list for a volume name that ends with `_archiver-data`. The part before that will be your project's directory name. For example, if your project is in a folder named `OpenArchiver`, the volume will be `openarchiver_archiver-data` But it can be a randomly generated hash.
|
||||
|
||||
3. **Inspect the correct volume**:
|
||||
|
||||
Once you've identified the correct volume name, use it in the `inspect` command. For example:
|
||||
|
||||
```bash
|
||||
docker volume inspect <your_volume_name_here>
|
||||
```
|
||||
|
||||
This will give you the correct `Mountpoint` path where your data is being stored. It will look something like this (the exact path will vary depending on your system):
|
||||
|
||||
```json
|
||||
{
|
||||
"CreatedAt": "2025-07-25T11:22:19Z",
|
||||
"Driver": "local",
|
||||
"Labels": {
|
||||
"com.docker.compose.config-hash": "---",
|
||||
"com.docker.compose.project": "---",
|
||||
"com.docker.compose.version": "2.38.2",
|
||||
"com.docker.compose.volume": "us8wwos0o4ok4go4gc8cog84_archiver-data"
|
||||
},
|
||||
"Mountpoint": "/var/lib/docker/volumes/us8wwos0o4ok4go4gc8cog84_archiver-data/_data",
|
||||
"Name": "us8wwos0o4ok4go4gc8cog84_archiver-data",
|
||||
"Options": null,
|
||||
"Scope": "local"
|
||||
}
|
||||
```
|
||||
|
||||
In this example, the data is located at `/var/lib/docker/volumes/us8wwos0o4ok4go4gc8cog84_archiver-data/_data`. You can then `cd` into that directory to see your files.
|
||||
|
||||
### To save data to a specific folder
|
||||
|
||||
To save the data to a specific folder on your machine, you'll need to make a change to your `docker-compose.yml`. You need to switch from a named volume to a "bind mount".
|
||||
|
||||
Here’s how you can do it:
|
||||
|
||||
1. **Edit `docker-compose.yml`**:
|
||||
|
||||
Open the `docker-compose.yml` file and find the `open-archiver` service. You're going to change the `volumes` section.
|
||||
|
||||
**Change this:**
|
||||
|
||||
```yaml
|
||||
services:
|
||||
open-archiver:
|
||||
# ... other config
|
||||
volumes:
|
||||
- archiver-data:/var/data/open-archiver
|
||||
```
|
||||
|
||||
**To this:**
|
||||
|
||||
```yaml
|
||||
services:
|
||||
open-archiver:
|
||||
# ... other config
|
||||
volumes:
|
||||
- ./data/open-archiver:/var/data/open-archiver
|
||||
```
|
||||
|
||||
You'll also want to remove the `archiver-data` volume definition at the bottom of the file, since it's no longer needed.
|
||||
|
||||
**Remove this whole block:**
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
# ... other volumes
|
||||
archiver-data:
|
||||
driver: local
|
||||
```
|
||||
|
||||
2. **Restart your containers**:
|
||||
|
||||
After you've saved the changes, run the following command in your terminal to apply them. The `--force-recreate` flag will ensure the container is recreated with the new volume settings.
|
||||
|
||||
```bash
|
||||
docker-compose up -d --force-recreate
|
||||
```
|
||||
|
||||
After this, any new data will be saved directly into the `./data/open-archiver` folder in your project directory.
|
||||
37
docs/user-guides/integrity-check.md
Normal file
37
docs/user-guides/integrity-check.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Integrity Check
|
||||
|
||||
Open Archiver allows you to verify the integrity of your archived emails and their attachments. This guide explains how the integrity check works and what the results mean.
|
||||
|
||||
## How It Works
|
||||
|
||||
When an email is archived, Open Archiver calculates a unique cryptographic signature (a SHA256 hash) for the email's raw `.eml` file and for each of its attachments. These signatures are stored in the database alongside the email's metadata.
|
||||
|
||||
The integrity check feature recalculates these signatures for the stored files and compares them to the original signatures stored in the database. This process allows you to verify that the content of your archived emails has not been altered, corrupted, or tampered with since the moment they were archived.
|
||||
|
||||
## The Integrity Report
|
||||
|
||||
When you view an email in the Open Archiver interface, an integrity report is automatically generated and displayed. This report provides a clear, at-a-glance status for the email file and each of its attachments.
|
||||
|
||||
### Statuses
|
||||
|
||||
- **Valid (Green Badge):** A "Valid" status means that the current signature of the file matches the original signature stored in the database. This is the expected status and indicates that the file's integrity is intact.
|
||||
|
||||
- **Invalid (Red Badge):** An "Invalid" status means that the current signature of the file does _not_ match the original signature. This indicates that the file's content has changed since it was archived.
|
||||
|
||||
### Reasons for an "Invalid" Status
|
||||
|
||||
If a file is marked as "Invalid," you can hover over the badge to see a reason for the failure. Common reasons include:
|
||||
|
||||
- **Stored hash does not match current hash:** This is the most common reason and indicates that the file's content has been modified. This could be due to accidental changes, data corruption, or unauthorized tampering.
|
||||
|
||||
- **Could not read attachment file from storage:** This message indicates that the file could not be read from its storage location. This could be due to a storage system issue, a file permission problem, or because the file has been deleted.
|
||||
|
||||
## What to Do If an Integrity Check Fails
|
||||
|
||||
If you encounter an "Invalid" status for an email or attachment, it is important to investigate the issue. Here are some steps you can take:
|
||||
|
||||
1. **Check Storage:** Verify that the file exists in its storage location and that its permissions are correct.
|
||||
2. **Review Audit Logs:** If you have audit logging enabled, review the logs for any unauthorized access or modifications to the file.
|
||||
3. **Restore from Backup:** If you suspect data corruption, you may need to restore the affected file from a backup.
|
||||
|
||||
The integrity check feature is a crucial tool for ensuring the long-term reliability and trustworthiness of your email archive. By regularly monitoring the integrity of your archived data, you can be confident that your records are accurate and complete.
|
||||
32
docs/user-guides/settings/system.md
Normal file
32
docs/user-guides/settings/system.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# System Settings
|
||||
|
||||
System settings allow administrators to configure the global look and theme of the application. These settings apply to all users.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Language
|
||||
|
||||
This setting determines the default display language for the application UI. The selected language will be used for all interface elements, including menus, labels, and messages.
|
||||
|
||||
> **Important:** When the language is changed, the backend (API) language will only change after a restart of the server. The frontend will update immediately.
|
||||
|
||||
Supported languages:
|
||||
|
||||
- English
|
||||
- German
|
||||
- French
|
||||
- Estonian
|
||||
- Spanish
|
||||
- Italian
|
||||
- Portuguese
|
||||
- Dutch
|
||||
- Greek
|
||||
- Japanese
|
||||
|
||||
### Default Theme
|
||||
|
||||
This setting controls the default color theme for the application. Users can choose between light, dark, or system default. The system default theme will sync with the user's operating system theme.
|
||||
|
||||
### Support Email
|
||||
|
||||
This setting allows administrators to provide a public-facing email address for user support inquiries. This email address may be displayed on error pages or in other areas where users may need to contact support.
|
||||
75
docs/user-guides/troubleshooting/cors-errors.md
Normal file
75
docs/user-guides/troubleshooting/cors-errors.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Troubleshooting CORS Errors
|
||||
|
||||
Cross-Origin Resource Sharing (CORS) is a security feature that controls how web applications in one domain can request and interact with resources in another. If not configured correctly, you may encounter errors when performing actions like uploading files.
|
||||
|
||||
This guide will help you diagnose and resolve common CORS-related issues.
|
||||
|
||||
## Symptoms
|
||||
|
||||
You may be experiencing a CORS issue if you see one of the following errors in your browser's developer console or in the application's logs:
|
||||
|
||||
- `TypeError: fetch failed`
|
||||
- `Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource.`
|
||||
- `Unexpected token 'C', "Cross-site"... is not valid JSON`
|
||||
- A JSON error response similar to the following:
|
||||
```json
|
||||
{
|
||||
"message": "CORS Error: This origin is not allowed.",
|
||||
"requiredOrigin": "http://localhost:3000",
|
||||
"receivedOrigin": "https://localhost:3000"
|
||||
}
|
||||
```
|
||||
|
||||
## Root Cause
|
||||
|
||||
These errors typically occur when the URL you are using to access the application in your browser does not exactly match the `APP_URL` configured in your `.env` file.
|
||||
|
||||
This can happen for several reasons:
|
||||
|
||||
- You are accessing the application via a different port.
|
||||
- You are using a reverse proxy that changes the protocol (e.g., from `http` to `https`).
|
||||
- The SvelteKit server, in a production build, is incorrectly guessing its public-facing URL.
|
||||
|
||||
## Solution
|
||||
|
||||
The solution is to ensure that the application's frontend and backend are correctly configured with the public-facing URL of your instance. This is done by setting two environment variables: `APP_URL` and `ORIGIN`.
|
||||
|
||||
1. **Open your `.env` file** in a text editor.
|
||||
|
||||
2. **Set `APP_URL`**: Define the `APP_URL` variable with the exact URL you use to access the application in your browser.
|
||||
|
||||
```env
|
||||
APP_URL=http://your-domain-or-ip:3000
|
||||
```
|
||||
|
||||
3. **Set `ORIGIN`**: The SvelteKit server requires a specific `ORIGIN` variable to correctly identify itself. This should always be set to the value of your `APP_URL`.
|
||||
|
||||
```env
|
||||
ORIGIN=$APP_URL
|
||||
```
|
||||
|
||||
By using `$APP_URL`, you ensure that both variables are always in sync.
|
||||
|
||||
### Example Configuration
|
||||
|
||||
If you are running the application locally on port `3000`, your configuration should look like this:
|
||||
|
||||
```env
|
||||
APP_URL=http://localhost:3000
|
||||
ORIGIN=$APP_URL
|
||||
```
|
||||
|
||||
If your application is behind a reverse proxy and is accessible at `https://archive.mycompany.com`, your configuration should be:
|
||||
|
||||
```env
|
||||
APP_URL=https://archive.mycompany.com
|
||||
ORIGIN=$APP_URL
|
||||
```
|
||||
|
||||
After making these changes to your `.env` file, you must restart the application for them to take effect:
|
||||
|
||||
```bash
|
||||
docker compose up -d --force-recreate
|
||||
```
|
||||
|
||||
This will ensure that the backend's CORS policy and the frontend server's origin are correctly aligned, resolving the errors.
|
||||
141
docs/user-guides/upgrade-and-migration/meilisearch-upgrade.md
Normal file
141
docs/user-guides/upgrade-and-migration/meilisearch-upgrade.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Upgrading Meilisearch
|
||||
|
||||
Meilisearch, the search engine used by Open Archiver, requires a manual data migration process when upgrading to a new version. This is because Meilisearch databases are only compatible with the specific version that created them.
|
||||
|
||||
If an Open Archiver upgrade includes a major Meilisearch version change, you will need to migrate your search index by following the process below.
|
||||
|
||||
## Experimental: Dumpless Upgrade
|
||||
|
||||
> **Warning:** This feature is currently **experimental**. We do not recommend using it for production environments until it is marked as stable. Please use the [standard migration process](#standard-migration-process-recommended) instead. Proceed with caution.
|
||||
|
||||
Meilisearch recently introduced an experimental "dumpless" upgrade method. This allows you to migrate the database to a new Meilisearch version without manually creating and importing a dump. However, please note that **dumpless upgrades are not currently atomic**. If the process fails, your database may become corrupted, resulting in data loss.
|
||||
|
||||
**Prerequisite: Create a Snapshot**
|
||||
|
||||
Before attempting a dumpless upgrade, you **must** take a snapshot of your instance. This ensures you have a recovery point if the upgrade fails. Learn how to create snapshots in the [official Meilisearch documentation](https://www.meilisearch.com/docs/learn/data_backup/snapshots).
|
||||
|
||||
### How to Enable
|
||||
|
||||
To perform a dumpless upgrade, you need to configure your Meilisearch instance with the experimental flag. You can do this in one of two ways:
|
||||
|
||||
**Option 1: Using an Environment Variable**
|
||||
|
||||
Add the `MEILI_EXPERIMENTAL_DUMPLESS_UPGRADE` environment variable to your `docker-compose.yml` file for the Meilisearch service.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.x # The new version you want to upgrade to
|
||||
environment:
|
||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
|
||||
- MEILI_EXPERIMENTAL_DUMPLESS_UPGRADE=true
|
||||
```
|
||||
|
||||
**Option 2: Using a CLI Option**
|
||||
|
||||
Alternatively, you can pass the `--experimental-dumpless-upgrade` flag in the command section of your `docker-compose.yml`.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.x # The new version you want to upgrade to
|
||||
command: meilisearch --experimental-dumpless-upgrade
|
||||
```
|
||||
|
||||
After updating your configuration, restart your container:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Meilisearch will attempt to migrate your database to the new version automatically.
|
||||
|
||||
---
|
||||
|
||||
## Standard Migration Process (Recommended)
|
||||
|
||||
For self-hosted instances using Docker Compose, the recommended migration process involves creating a data dump from your current Meilisearch instance, upgrading the Docker image, and then importing that dump into the new version.
|
||||
|
||||
### Step 1: Create a Dump
|
||||
|
||||
Before upgrading, you must create a dump of your existing Meilisearch data. You can do this by sending a POST request to the `/dumps` endpoint of the Meilisearch API.
|
||||
|
||||
1. **Find your Meilisearch container name**:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Look for the service name that corresponds to Meilisearch, usually `meilisearch`.
|
||||
|
||||
2. **Execute the dump command**:
|
||||
You will need your Meilisearch Admin API key, which can be found in your `.env` file as `MEILI_MASTER_KEY`.
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:7700/dumps' \
|
||||
-H "Authorization: Bearer YOUR_MEILI_MASTER_KEY"
|
||||
```
|
||||
|
||||
This will start the dump creation process. The dump file will be created inside the `meili_data` volume used by the Meilisearch container.
|
||||
|
||||
3. **Monitor the dump status**:
|
||||
The dump creation request returns a `taskUid`. You can use this to check the status of the dump.
|
||||
|
||||
For more details on dump and import, see the [official Meilisearch documentation](https://www.meilisearch.com/docs/learn/update_and_migration/updating).
|
||||
|
||||
### Step 2: Upgrade Your Open Archiver Instance
|
||||
|
||||
Once the dump is successfully created, you can proceed with the standard Open Archiver upgrade process.
|
||||
|
||||
1. **Pull the latest changes and Docker images**:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose pull
|
||||
```
|
||||
|
||||
2. **Stop the running services**:
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### Step 3: Import the Dump
|
||||
|
||||
Now, you need to restart the services while telling Meilisearch to import from your dump file.
|
||||
|
||||
1. **Modify `docker-compose.yml`**:
|
||||
You need to temporarily add the `--import-dump` flag to the Meilisearch service command. Find the `meilisearch` service in your `docker-compose.yml` and modify the `command` section.
|
||||
|
||||
You will need the name of your dump file. It will be a `.dump` file located in the directory mapped to `/meili_data` inside the container.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
meilisearch:
|
||||
# ... other service config
|
||||
command:
|
||||
[
|
||||
'--master-key=${MEILI_MASTER_KEY}',
|
||||
'--env=production',
|
||||
'--import-dump=/meili_data/dumps/YOUR_DUMP_FILE.dump',
|
||||
]
|
||||
```
|
||||
|
||||
2. **Restart the services**:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
Meilisearch will now start and import the data from the dump file. This may take some time depending on the size of your index.
|
||||
|
||||
### Step 4: Clean Up
|
||||
|
||||
Once the import is complete and you have verified that your search is working correctly, you should remove the `--import-dump` flag from your `docker-compose.yml` to prevent it from running on every startup.
|
||||
|
||||
1. **Remove the `--import-dump` line** from the `command` section of the `meilisearch` service in `docker-compose.yml`.
|
||||
2. **Restart the services** one last time:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Your Meilisearch instance is now upgraded and running with your migrated data.
|
||||
|
||||
For more advanced scenarios or troubleshooting, please refer to the **[official Meilisearch migration guide](https://www.meilisearch.com/docs/learn/update_and_migration/updating)**.
|
||||
42
docs/user-guides/upgrade-and-migration/upgrade.md
Normal file
42
docs/user-guides/upgrade-and-migration/upgrade.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Upgrading Your Instance
|
||||
|
||||
This guide provides instructions for upgrading your Open Archiver instance to the latest version.
|
||||
|
||||
## Checking for New Versions
|
||||
|
||||
Open Archiver automatically checks for new versions and will display a notification in the footer of the web interface when an update is available. You can find a list of all releases and their release notes on the [GitHub Releases](https://github.com/LogicLabs-OU/OpenArchiver/releases) page.
|
||||
|
||||
## Upgrading Your Instance
|
||||
|
||||
To upgrade your Open Archiver instance, follow these steps:
|
||||
|
||||
1. **Pull the latest changes from the repository**:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
```
|
||||
|
||||
2. **Pull the latest Docker images**:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
```
|
||||
|
||||
3. **Restart the services with the new images**:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This will restart your Open Archiver instance with the latest version of the application.
|
||||
|
||||
## Migrating Data
|
||||
|
||||
When you upgrade to a new version, database migrations are applied automatically when the application starts up. This ensures that your database schema is always up-to-date with the latest version of the application.
|
||||
|
||||
No manual intervention is required for database migrations.
|
||||
|
||||
## Upgrading Meilisearch
|
||||
|
||||
When an Open Archiver update includes a major version change for Meilisearch, you will need to manually migrate your search data. This process is not covered by the standard upgrade commands.
|
||||
|
||||
For detailed instructions, please see the [Meilisearch Upgrade Guide](./meilisearch-upgrade.md).
|
||||
79
open-archiver.yml
Normal file
79
open-archiver.yml
Normal file
@@ -0,0 +1,79 @@
|
||||
# documentation: https://openarchiver.com
|
||||
# slogan: A self-hosted, open-source email archiving solution with full-text search capability.
|
||||
# tags: email archiving,email,compliance,search
|
||||
# logo: svgs/openarchiver.svg
|
||||
# port: 3000
|
||||
|
||||
services:
|
||||
open-archiver:
|
||||
image: logiclabshq/open-archiver:latest
|
||||
environment:
|
||||
- SERVICE_URL_3000
|
||||
- SERVICE_URL=${SERVICE_URL_3000}
|
||||
- PORT_BACKEND=${PORT_BACKEND:-4000}
|
||||
- PORT_FRONTEND=${PORT_FRONTEND:-3000}
|
||||
- NODE_ENV=${NODE_ENV:-production}
|
||||
- SYNC_FREQUENCY=${SYNC_FREQUENCY:-* * * * *}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-open_archive}
|
||||
- POSTGRES_USER=${POSTGRES_USER:-admin}
|
||||
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||||
- MEILI_MASTER_KEY=${SERVICE_PASSWORD_MEILISEARCH}
|
||||
- MEILI_HOST=http://meilisearch:7700
|
||||
- REDIS_HOST=valkey
|
||||
- REDIS_PORT=6379
|
||||
- REDIS_USER=default
|
||||
- REDIS_PASSWORD=${SERVICE_PASSWORD_VALKEY}
|
||||
- REDIS_TLS_ENABLED=false
|
||||
- STORAGE_TYPE=${STORAGE_TYPE:-local}
|
||||
- STORAGE_LOCAL_ROOT_PATH=${STORAGE_LOCAL_ROOT_PATH:-/var/data/open-archiver}
|
||||
- BODY_SIZE_LIMIT=${BODY_SIZE_LIMIT:-100M}
|
||||
- STORAGE_S3_ENDPOINT=${STORAGE_S3_ENDPOINT}
|
||||
- STORAGE_S3_BUCKET=${STORAGE_S3_BUCKET}
|
||||
- STORAGE_S3_ACCESS_KEY_ID=${STORAGE_S3_ACCESS_KEY_ID}
|
||||
- STORAGE_S3_SECRET_ACCESS_KEY=${STORAGE_S3_SECRET_ACCESS_KEY}
|
||||
- STORAGE_S3_REGION=${STORAGE_S3_REGION}
|
||||
- STORAGE_S3_FORCE_PATH_STYLE=${STORAGE_S3_FORCE_PATH_STYLE:-false}
|
||||
- JWT_SECRET=${SERVICE_BASE64_128_JWT}
|
||||
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-7d}
|
||||
- ENCRYPTION_KEY=${SERVICE_BASE64_64_ENCRYPTIONKEY}
|
||||
- RATE_LIMIT_WINDOW_MS=${RATE_LIMIT_WINDOW_MS:-60000}
|
||||
- RATE_LIMIT_MAX_REQUESTS=${RATE_LIMIT_MAX_REQUESTS:-100}
|
||||
volumes:
|
||||
- archiver-data:/var/data/open-archiver
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_started
|
||||
meilisearch:
|
||||
condition: service_started
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
- POSTGRES_DB=${POSTGRES_DB}
|
||||
- POSTGRES_USER=${POSTGRES_USER}
|
||||
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
|
||||
- LC_ALL=C
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}']
|
||||
interval: 10s
|
||||
timeout: 20s
|
||||
retries: 10
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:8-alpine
|
||||
command: valkey-server --requirepass ${SERVICE_PASSWORD_VALKEY}
|
||||
volumes:
|
||||
- valkeydata:/data
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.15
|
||||
environment:
|
||||
- MEILI_MASTER_KEY=${SERVICE_PASSWORD_MEILISEARCH}
|
||||
- MEILI_SCHEDULE_SNAPSHOT=86400
|
||||
volumes:
|
||||
- meilidata:/meili_data
|
||||
80
package.json
80
package.json
@@ -1,32 +1,52 @@
|
||||
{
|
||||
"name": "open-archiver",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -- pnpm --filter \"./packages/*\" --parallel dev",
|
||||
"build": "pnpm --filter \"./packages/*\" --parallel build",
|
||||
"start": "dotenv -- pnpm --filter \"./packages/*\" --parallel start",
|
||||
"start:workers": "dotenv -- concurrently \"pnpm --filter @open-archiver/backend start:ingestion-worker\" \"pnpm --filter @open-archiver/backend start:indexing-worker\" \"pnpm --filter @open-archiver/backend start:sync-scheduler\"",
|
||||
"start:workers:dev": "dotenv -- concurrently \"pnpm --filter @open-archiver/backend start:ingestion-worker:dev\" \"pnpm --filter @open-archiver/backend start:indexing-worker:dev\" \"pnpm --filter @open-archiver/backend start:sync-scheduler:dev\"",
|
||||
"db:generate": "dotenv -- pnpm --filter @open-archiver/backend db:generate",
|
||||
"db:migrate": "dotenv -- pnpm --filter @open-archiver/backend db:migrate",
|
||||
"db:migrate:dev": "dotenv -- pnpm --filter @open-archiver/backend db:migrate:dev",
|
||||
"docker-start": "pnpm db:migrate && concurrently \"pnpm start:workers\" \"pnpm start\""
|
||||
},
|
||||
"dependencies": {
|
||||
"concurrently": "^9.2.0",
|
||||
"dotenv-cli": "8.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
"pnpm": "10.13.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
"name": "open-archiver",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"license": "SEE LICENSE IN LICENSE file",
|
||||
"scripts": {
|
||||
"build:oss": "pnpm --filter \"./packages/*\" --filter \"!./packages/enterprise\" --filter \"./apps/open-archiver\" build",
|
||||
"build:enterprise": "cross-env VITE_ENTERPRISE_MODE=true pnpm build",
|
||||
"start:oss": "dotenv -- concurrently \"node apps/open-archiver/dist/index.js\" \"pnpm --filter @open-archiver/frontend start\"",
|
||||
"start:enterprise": "dotenv -- concurrently \"node apps/open-archiver-enterprise/dist/index.js\" \"pnpm --filter @open-archiver/frontend start\"",
|
||||
"dev:enterprise": "cross-env VITE_ENTERPRISE_MODE=true dotenv -- pnpm --filter \"@open-archiver/*\" --filter \"open-archiver-enterprise-app\" --parallel dev & pnpm run start:workers:dev",
|
||||
"dev:oss": "dotenv -- pnpm --filter \"./packages/*\" --filter \"!./packages/@open-archiver/enterprise\" --filter \"open-archiver-app\" --parallel dev & pnpm run start:workers:dev",
|
||||
"build": "pnpm --filter \"./packages/*\" --filter \"./apps/*\" build",
|
||||
"start": "dotenv -- pnpm --filter \"open-archiver-app\" --parallel start",
|
||||
"start:workers": "dotenv -- concurrently \"pnpm --filter @open-archiver/backend start:ingestion-worker\" \"pnpm --filter @open-archiver/backend start:indexing-worker\" \"pnpm --filter @open-archiver/backend start:sync-scheduler\"",
|
||||
"start:workers:dev": "dotenv -- concurrently \"pnpm --filter @open-archiver/backend start:ingestion-worker:dev\" \"pnpm --filter @open-archiver/backend start:indexing-worker:dev\" \"pnpm --filter @open-archiver/backend start:sync-scheduler:dev\"",
|
||||
"db:generate": "dotenv -- pnpm --filter @open-archiver/backend db:generate",
|
||||
"db:migrate": "dotenv -- pnpm --filter @open-archiver/backend db:migrate",
|
||||
"db:migrate:dev": "dotenv -- pnpm --filter @open-archiver/backend db:migrate:dev",
|
||||
"docker-start:oss": "concurrently \"pnpm start:workers\" \"pnpm start:oss\"",
|
||||
"docker-start:enterprise": "concurrently \"pnpm start:workers\" \"pnpm start:enterprise\"",
|
||||
"docs:gen-spec": "node packages/backend/scripts/generate-openapi-spec.mjs",
|
||||
"docs:dev": "pnpm docs:gen-spec && vitepress dev docs --port 3009",
|
||||
"docs:build": "pnpm docs:gen-spec && vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"concurrently": "^9.2.0",
|
||||
"dotenv-cli": "8.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^10.0.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"typescript": "5.8.3",
|
||||
"vitepress": "^1.6.4",
|
||||
"vitepress-openapi": "^0.1.18"
|
||||
},
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
"pnpm": "10.13.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,16 @@ import { config } from 'dotenv';
|
||||
config();
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error('DATABASE_URL is not set in the .env file');
|
||||
throw new Error('DATABASE_URL is not set in the .env file');
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/database/schema.ts',
|
||||
out: './src/database/migrations',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
schema: './src/database/schema.ts',
|
||||
out: './src/database/migrations',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
});
|
||||
|
||||
@@ -1,65 +1,89 @@
|
||||
{
|
||||
"name": "@open-archiver/backend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts ",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"start:ingestion-worker": "node dist/workers/ingestion.worker.js",
|
||||
"start:indexing-worker": "node dist/workers/indexing.worker.js",
|
||||
"start:sync-scheduler": "node dist/jobs/schedulers/sync-scheduler.js",
|
||||
"start:ingestion-worker:dev": "ts-node-dev --respawn --transpile-only src/workers/ingestion.worker.ts",
|
||||
"start:indexing-worker:dev": "ts-node-dev --respawn --transpile-only src/workers/indexing.worker.ts",
|
||||
"start:sync-scheduler:dev": "ts-node-dev --respawn --transpile-only src/jobs/schedulers/sync-scheduler.ts",
|
||||
"db:generate": "drizzle-kit generate --config=drizzle.config.ts",
|
||||
"db:push": "drizzle-kit push --config=drizzle.config.ts",
|
||||
"db:migrate": "node dist/database/migrate.js",
|
||||
"db:migrate:dev": "ts-node-dev src/database/migrate.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"@aws-sdk/client-s3": "^3.844.0",
|
||||
"@aws-sdk/lib-storage": "^3.844.0",
|
||||
"@azure/msal-node": "^3.6.3",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"@open-archiver/types": "workspace:*",
|
||||
"axios": "^1.10.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"bullmq": "^5.56.3",
|
||||
"cross-fetch": "^4.1.0",
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"dotenv": "^17.2.0",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"express": "^5.1.0",
|
||||
"express-validator": "^7.2.1",
|
||||
"google-auth-library": "^10.1.0",
|
||||
"googleapis": "^152.0.0",
|
||||
"imapflow": "^1.0.191",
|
||||
"jose": "^6.0.11",
|
||||
"mailparser": "^3.7.4",
|
||||
"mammoth": "^1.9.1",
|
||||
"meilisearch": "^0.51.0",
|
||||
"pdf2json": "^3.1.6",
|
||||
"pg": "^8.16.3",
|
||||
"pino": "^9.7.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"postgres": "^3.4.7",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@bull-board/api": "^6.11.0",
|
||||
"@bull-board/express": "^6.11.0",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/microsoft-graph": "^2.40.1",
|
||||
"@types/node": "^24.0.12",
|
||||
"bull-board": "^2.1.3",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
"name": "@open-archiver/backend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "SEE LICENSE IN LICENSE file",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./*": "./dist/*.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && pnpm copy-assets",
|
||||
"dev": "tsc --watch",
|
||||
"copy-assets": "cp -r src/locales dist/locales",
|
||||
"start:ingestion-worker": "node dist/workers/ingestion.worker.js",
|
||||
"start:indexing-worker": "node dist/workers/indexing.worker.js",
|
||||
"start:sync-scheduler": "node dist/jobs/schedulers/sync-scheduler.js",
|
||||
"start:ingestion-worker:dev": "ts-node-dev --respawn --transpile-only src/workers/ingestion.worker.ts",
|
||||
"start:indexing-worker:dev": "ts-node-dev --respawn --transpile-only src/workers/indexing.worker.ts",
|
||||
"start:sync-scheduler:dev": "ts-node-dev --respawn --transpile-only src/jobs/schedulers/sync-scheduler.ts",
|
||||
"db:generate": "drizzle-kit generate --config=drizzle.config.ts",
|
||||
"db:push": "drizzle-kit push --config=drizzle.config.ts",
|
||||
"db:migrate": "node dist/database/migrate.js",
|
||||
"db:migrate:dev": "ts-node-dev src/database/migrate.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.844.0",
|
||||
"@aws-sdk/lib-storage": "^3.844.0",
|
||||
"@azure/msal-node": "^3.6.3",
|
||||
"@casl/ability": "^6.7.3",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"@open-archiver/types": "workspace:*",
|
||||
"archiver": "^7.0.1",
|
||||
"axios": "^1.10.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"bullmq": "^5.56.3",
|
||||
"busboy": "^1.6.0",
|
||||
"cors": "^2.8.5",
|
||||
"cross-fetch": "^4.1.0",
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"dotenv": "^17.2.0",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^8.0.1",
|
||||
"express-validator": "^7.2.1",
|
||||
"google-auth-library": "^10.1.0",
|
||||
"googleapis": "^152.0.0",
|
||||
"i18next": "^25.4.2",
|
||||
"i18next-fs-backend": "^2.6.0",
|
||||
"i18next-http-middleware": "^3.8.0",
|
||||
"imapflow": "^1.0.191",
|
||||
"jose": "^6.0.11",
|
||||
"mailparser": "^3.7.4",
|
||||
"mammoth": "^1.9.1",
|
||||
"meilisearch": "^0.51.0",
|
||||
"multer": "^2.0.2",
|
||||
"nodemailer": "^8.0.2",
|
||||
"pdf2json": "^3.1.6",
|
||||
"pg": "^8.16.3",
|
||||
"pino": "^9.7.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"postgres": "^3.4.7",
|
||||
"pst-extractor": "^1.11.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sqlite3": "^5.1.7",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"yauzl": "^3.2.0",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/microsoft-graph": "^2.40.1",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.0.12",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/swagger-jsdoc": "^6.0.4",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
735
packages/backend/scripts/generate-openapi-spec.mjs
Normal file
735
packages/backend/scripts/generate-openapi-spec.mjs
Normal file
@@ -0,0 +1,735 @@
|
||||
/**
|
||||
* Generates the OpenAPI specification from swagger-jsdoc annotations in the route files.
|
||||
* Outputs the spec to docs/api/openapi.json for use with vitepress-openapi.
|
||||
*
|
||||
* Run: node packages/backend/scripts/generate-openapi-spec.mjs
|
||||
*/
|
||||
import swaggerJsdoc from 'swagger-jsdoc';
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const options = {
|
||||
definition: {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'Open Archiver API',
|
||||
version: '1.0.0',
|
||||
description:
|
||||
'REST API for Open Archiver — an open-source email archiving platform. All authenticated endpoints require a Bearer JWT token obtained from `POST /v1/auth/login`, or an API key passed as a Bearer token.',
|
||||
license: {
|
||||
name: 'SEE LICENSE IN LICENSE',
|
||||
url: 'https://github.com/LogicLabs-OU/OpenArchiver/blob/main/LICENSE',
|
||||
},
|
||||
contact: {
|
||||
name: 'Open Archiver',
|
||||
url: 'https://openarchiver.com',
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: 'http://localhost:3000',
|
||||
description: 'Local development',
|
||||
},
|
||||
],
|
||||
// Both security schemes apply globally; individual endpoints may override
|
||||
security: [{ bearerAuth: [] }, { apiKeyAuth: [] }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description:
|
||||
'JWT obtained from `POST /v1/auth/login`. Pass as `Authorization: Bearer <token>`.',
|
||||
},
|
||||
apiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'X-API-KEY',
|
||||
description:
|
||||
'API key generated via `POST /v1/api-keys`. Pass as `X-API-KEY: <key>`.',
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
Unauthorized: {
|
||||
description: 'Authentication is required or the token is invalid/expired.',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ErrorMessage' },
|
||||
example: { message: 'Unauthorized' },
|
||||
},
|
||||
},
|
||||
},
|
||||
Forbidden: {
|
||||
description:
|
||||
'The authenticated user does not have permission to perform this action.',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ErrorMessage' },
|
||||
example: { message: 'Forbidden' },
|
||||
},
|
||||
},
|
||||
},
|
||||
NotFound: {
|
||||
description: 'The requested resource was not found.',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ErrorMessage' },
|
||||
example: { message: 'Not found' },
|
||||
},
|
||||
},
|
||||
},
|
||||
InternalServerError: {
|
||||
description: 'An unexpected error occurred on the server.',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { $ref: '#/components/schemas/ErrorMessage' },
|
||||
example: { message: 'Internal server error' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
// --- Shared utility schemas ---
|
||||
ErrorMessage: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'Human-readable error description.',
|
||||
example: 'An error occurred.',
|
||||
},
|
||||
},
|
||||
required: ['message'],
|
||||
},
|
||||
MessageResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: {
|
||||
type: 'string',
|
||||
example: 'Operation completed successfully.',
|
||||
},
|
||||
},
|
||||
required: ['message'],
|
||||
},
|
||||
ValidationError: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: {
|
||||
type: 'string',
|
||||
example: 'Request body is invalid.',
|
||||
},
|
||||
errors: {
|
||||
type: 'string',
|
||||
description: 'Zod validation error details.',
|
||||
},
|
||||
},
|
||||
required: ['message'],
|
||||
},
|
||||
// --- Auth ---
|
||||
LoginResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
description: 'JWT for authenticating subsequent requests.',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
},
|
||||
user: {
|
||||
$ref: '#/components/schemas/User',
|
||||
},
|
||||
},
|
||||
required: ['accessToken', 'user'],
|
||||
},
|
||||
// --- Users ---
|
||||
User: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'clx1y2z3a0000b4d2' },
|
||||
first_name: { type: 'string', nullable: true, example: 'Jane' },
|
||||
last_name: { type: 'string', nullable: true, example: 'Doe' },
|
||||
email: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
example: 'jane.doe@example.com',
|
||||
},
|
||||
role: {
|
||||
$ref: '#/components/schemas/Role',
|
||||
nullable: true,
|
||||
},
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
required: ['id', 'email', 'createdAt'],
|
||||
},
|
||||
// --- IAM ---
|
||||
Role: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'clx1y2z3a0000b4d2' },
|
||||
slug: { type: 'string', nullable: true, example: 'predefined_super_admin' },
|
||||
name: { type: 'string', example: 'Super Admin' },
|
||||
policies: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/CaslPolicy' },
|
||||
},
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
updatedAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
required: ['id', 'name', 'policies', 'createdAt', 'updatedAt'],
|
||||
},
|
||||
CaslPolicy: {
|
||||
type: 'object',
|
||||
description:
|
||||
'An CASL-style permission policy statement. `action` and `subject` can be strings or arrays of strings. `conditions` optionally restricts access to specific resource attributes.',
|
||||
properties: {
|
||||
action: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'string',
|
||||
example: 'read',
|
||||
},
|
||||
{
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
example: ['read', 'search'],
|
||||
},
|
||||
],
|
||||
},
|
||||
subject: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'string',
|
||||
example: 'archive',
|
||||
},
|
||||
{
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
example: ['archive', 'ingestion'],
|
||||
},
|
||||
],
|
||||
},
|
||||
conditions: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Optional attribute-level conditions. Supports `${user.id}` interpolation.',
|
||||
example: { userId: '${user.id}' },
|
||||
},
|
||||
},
|
||||
required: ['action', 'subject'],
|
||||
},
|
||||
// --- API Keys ---
|
||||
ApiKey: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'clx1y2z3a0000b4d2' },
|
||||
name: { type: 'string', example: 'CI/CD Pipeline Key' },
|
||||
key: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Partial/masked key — the raw value is only available at creation time.',
|
||||
example: 'oa_live_abc1...',
|
||||
},
|
||||
expiresAt: { type: 'string', format: 'date-time' },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
required: ['id', 'name', 'expiresAt', 'createdAt'],
|
||||
},
|
||||
// --- Ingestion ---
|
||||
SafeIngestionSource: {
|
||||
type: 'object',
|
||||
description: 'An ingestion source with sensitive credential fields removed.',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'clx1y2z3a0000b4d2' },
|
||||
name: { type: 'string', example: 'Company Google Workspace' },
|
||||
provider: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'google_workspace',
|
||||
'microsoft_365',
|
||||
'generic_imap',
|
||||
'pst_import',
|
||||
'eml_import',
|
||||
'mbox_import',
|
||||
],
|
||||
example: 'google_workspace',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'active',
|
||||
'paused',
|
||||
'error',
|
||||
'pending_auth',
|
||||
'syncing',
|
||||
'importing',
|
||||
'auth_success',
|
||||
'imported',
|
||||
],
|
||||
example: 'active',
|
||||
},
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
updatedAt: { type: 'string', format: 'date-time' },
|
||||
lastSyncStartedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
lastSyncFinishedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
lastSyncStatusMessage: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['id', 'name', 'provider', 'status', 'createdAt', 'updatedAt'],
|
||||
},
|
||||
CreateIngestionSourceDto: {
|
||||
type: 'object',
|
||||
required: ['name', 'provider', 'providerConfig'],
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
example: 'Company Google Workspace',
|
||||
},
|
||||
provider: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'google_workspace',
|
||||
'microsoft_365',
|
||||
'generic_imap',
|
||||
'pst_import',
|
||||
'eml_import',
|
||||
'mbox_import',
|
||||
],
|
||||
},
|
||||
providerConfig: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Provider-specific configuration. See the ingestion source guides for the required fields per provider.',
|
||||
example: {
|
||||
serviceAccountKeyJson: '{"type":"service_account",...}',
|
||||
impersonatedAdminEmail: 'admin@example.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UpdateIngestionSourceDto: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
provider: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'google_workspace',
|
||||
'microsoft_365',
|
||||
'generic_imap',
|
||||
'pst_import',
|
||||
'eml_import',
|
||||
'mbox_import',
|
||||
],
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'active',
|
||||
'paused',
|
||||
'error',
|
||||
'pending_auth',
|
||||
'syncing',
|
||||
'importing',
|
||||
'auth_success',
|
||||
'imported',
|
||||
],
|
||||
},
|
||||
providerConfig: { type: 'object' },
|
||||
},
|
||||
},
|
||||
// --- Archived Emails ---
|
||||
Recipient: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', nullable: true, example: 'John Doe' },
|
||||
email: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
example: 'john.doe@example.com',
|
||||
},
|
||||
},
|
||||
required: ['email'],
|
||||
},
|
||||
Attachment: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'clx1y2z3a0000b4d2' },
|
||||
filename: { type: 'string', example: 'invoice.pdf' },
|
||||
mimeType: { type: 'string', nullable: true, example: 'application/pdf' },
|
||||
sizeBytes: { type: 'integer', example: 204800 },
|
||||
storagePath: {
|
||||
type: 'string',
|
||||
example: 'open-archiver/attachments/abc123.pdf',
|
||||
},
|
||||
},
|
||||
required: ['id', 'filename', 'sizeBytes', 'storagePath'],
|
||||
},
|
||||
// Minimal representation of an email within a thread (returned alongside ArchivedEmail)
|
||||
ThreadEmail: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'ArchivedEmail ID.',
|
||||
example: 'clx1y2z3a0000b4d2',
|
||||
},
|
||||
subject: { type: 'string', nullable: true, example: 'Re: Q4 Invoice' },
|
||||
sentAt: { type: 'string', format: 'date-time' },
|
||||
senderEmail: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
example: 'finance@vendor.com',
|
||||
},
|
||||
},
|
||||
required: ['id', 'sentAt', 'senderEmail'],
|
||||
},
|
||||
ArchivedEmail: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'clx1y2z3a0000b4d2' },
|
||||
ingestionSourceId: { type: 'string', example: 'clx1y2z3a0000b4d2' },
|
||||
userEmail: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
example: 'user@company.com',
|
||||
},
|
||||
messageIdHeader: { type: 'string', nullable: true },
|
||||
sentAt: { type: 'string', format: 'date-time' },
|
||||
subject: { type: 'string', nullable: true, example: 'Q4 Invoice' },
|
||||
senderName: { type: 'string', nullable: true, example: 'Finance Dept' },
|
||||
senderEmail: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
example: 'finance@vendor.com',
|
||||
},
|
||||
recipients: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/Recipient' },
|
||||
},
|
||||
storagePath: { type: 'string' },
|
||||
storageHashSha256: {
|
||||
type: 'string',
|
||||
description:
|
||||
'SHA-256 hash of the raw email file, stored at archival time.',
|
||||
},
|
||||
sizeBytes: { type: 'integer' },
|
||||
isIndexed: { type: 'boolean' },
|
||||
hasAttachments: { type: 'boolean' },
|
||||
isOnLegalHold: { type: 'boolean' },
|
||||
archivedAt: { type: 'string', format: 'date-time' },
|
||||
attachments: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/Attachment' },
|
||||
},
|
||||
thread: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Other emails in the same thread, ordered by sentAt. Only present on single-email GET responses.',
|
||||
items: { $ref: '#/components/schemas/ThreadEmail' },
|
||||
},
|
||||
path: { type: 'string', nullable: true },
|
||||
tags: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: [
|
||||
'id',
|
||||
'ingestionSourceId',
|
||||
'userEmail',
|
||||
'sentAt',
|
||||
'senderEmail',
|
||||
'recipients',
|
||||
'storagePath',
|
||||
'storageHashSha256',
|
||||
'sizeBytes',
|
||||
'isIndexed',
|
||||
'hasAttachments',
|
||||
'isOnLegalHold',
|
||||
'archivedAt',
|
||||
],
|
||||
},
|
||||
PaginatedArchivedEmails: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/ArchivedEmail' },
|
||||
},
|
||||
total: { type: 'integer', example: 1234 },
|
||||
page: { type: 'integer', example: 1 },
|
||||
limit: { type: 'integer', example: 10 },
|
||||
},
|
||||
required: ['items', 'total', 'page', 'limit'],
|
||||
},
|
||||
// --- Search ---
|
||||
SearchResults: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
hits: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Array of matching archived email objects, potentially with highlighted fields.',
|
||||
items: { type: 'object' },
|
||||
},
|
||||
total: { type: 'integer', example: 42 },
|
||||
page: { type: 'integer', example: 1 },
|
||||
limit: { type: 'integer', example: 10 },
|
||||
totalPages: { type: 'integer', example: 5 },
|
||||
processingTimeMs: {
|
||||
type: 'integer',
|
||||
description: 'Meilisearch query processing time in milliseconds.',
|
||||
example: 12,
|
||||
},
|
||||
},
|
||||
required: ['hits', 'total', 'page', 'limit', 'totalPages', 'processingTimeMs'],
|
||||
},
|
||||
// --- Integrity ---
|
||||
IntegrityCheckResult: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['email', 'attachment'],
|
||||
description:
|
||||
'Whether this result is for the email itself or one of its attachments.',
|
||||
},
|
||||
id: { type: 'string', example: 'clx1y2z3a0000b4d2' },
|
||||
filename: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Attachment filename. Only present when `type` is `attachment`.',
|
||||
example: 'invoice.pdf',
|
||||
},
|
||||
isValid: {
|
||||
type: 'boolean',
|
||||
description: 'True if the stored and computed hashes match.',
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
description: 'Human-readable explanation if `isValid` is false.',
|
||||
},
|
||||
storedHash: {
|
||||
type: 'string',
|
||||
description: 'SHA-256 hash stored at archival time.',
|
||||
example: 'a3f1b2c4...',
|
||||
},
|
||||
computedHash: {
|
||||
type: 'string',
|
||||
description: 'SHA-256 hash computed during this verification run.',
|
||||
example: 'a3f1b2c4...',
|
||||
},
|
||||
},
|
||||
required: ['type', 'id', 'isValid', 'storedHash', 'computedHash'],
|
||||
},
|
||||
// --- Jobs ---
|
||||
QueueCounts: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
active: { type: 'integer', example: 0 },
|
||||
completed: { type: 'integer', example: 56 },
|
||||
failed: { type: 'integer', example: 4 },
|
||||
delayed: { type: 'integer', example: 0 },
|
||||
waiting: { type: 'integer', example: 0 },
|
||||
paused: { type: 'integer', example: 0 },
|
||||
},
|
||||
required: ['active', 'completed', 'failed', 'delayed', 'waiting', 'paused'],
|
||||
},
|
||||
QueueOverview: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', example: 'ingestion' },
|
||||
counts: { $ref: '#/components/schemas/QueueCounts' },
|
||||
},
|
||||
required: ['name', 'counts'],
|
||||
},
|
||||
Job: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', nullable: true, example: '1' },
|
||||
name: { type: 'string', example: 'initial-import' },
|
||||
data: {
|
||||
type: 'object',
|
||||
description: 'Job payload data.',
|
||||
example: { ingestionSourceId: 'clx1y2z3a0000b4d2' },
|
||||
},
|
||||
state: {
|
||||
type: 'string',
|
||||
enum: ['active', 'completed', 'failed', 'delayed', 'waiting', 'paused'],
|
||||
example: 'failed',
|
||||
},
|
||||
failedReason: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
example: 'Error: Connection timed out',
|
||||
},
|
||||
timestamp: { type: 'integer', example: 1678886400000 },
|
||||
processedOn: { type: 'integer', nullable: true, example: 1678886401000 },
|
||||
finishedOn: { type: 'integer', nullable: true, example: 1678886402000 },
|
||||
attemptsMade: { type: 'integer', example: 5 },
|
||||
stacktrace: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
returnValue: { nullable: true },
|
||||
ingestionSourceId: { type: 'string', nullable: true },
|
||||
error: {
|
||||
description: 'Shorthand copy of `failedReason` for easier access.',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: [
|
||||
'id',
|
||||
'name',
|
||||
'data',
|
||||
'state',
|
||||
'timestamp',
|
||||
'attemptsMade',
|
||||
'stacktrace',
|
||||
],
|
||||
},
|
||||
QueueDetails: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', example: 'ingestion' },
|
||||
counts: { $ref: '#/components/schemas/QueueCounts' },
|
||||
jobs: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/Job' },
|
||||
},
|
||||
pagination: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
currentPage: { type: 'integer', example: 1 },
|
||||
totalPages: { type: 'integer', example: 3 },
|
||||
totalJobs: { type: 'integer', example: 25 },
|
||||
limit: { type: 'integer', example: 10 },
|
||||
},
|
||||
required: ['currentPage', 'totalPages', 'totalJobs', 'limit'],
|
||||
},
|
||||
},
|
||||
required: ['name', 'counts', 'jobs', 'pagination'],
|
||||
},
|
||||
// --- Dashboard ---
|
||||
DashboardStats: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
totalEmailsArchived: { type: 'integer', example: 125000 },
|
||||
totalStorageUsed: {
|
||||
type: 'integer',
|
||||
description: 'Total storage used by all archived emails in bytes.',
|
||||
example: 5368709120,
|
||||
},
|
||||
failedIngestionsLast7Days: {
|
||||
type: 'integer',
|
||||
description:
|
||||
'Number of ingestion sources in error state updated in the last 7 days.',
|
||||
example: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
IngestionSourceStats: {
|
||||
type: 'object',
|
||||
description: 'Summary of an ingestion source including its storage usage.',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'clx1y2z3a0000b4d2' },
|
||||
name: { type: 'string', example: 'Company Google Workspace' },
|
||||
provider: { type: 'string', example: 'google_workspace' },
|
||||
status: { type: 'string', example: 'active' },
|
||||
storageUsed: {
|
||||
type: 'integer',
|
||||
description:
|
||||
'Total bytes stored for emails from this ingestion source.',
|
||||
example: 1073741824,
|
||||
},
|
||||
},
|
||||
required: ['id', 'name', 'provider', 'status', 'storageUsed'],
|
||||
},
|
||||
RecentSync: {
|
||||
type: 'object',
|
||||
description: 'Summary of a recent sync session.',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'clx1y2z3a0000b4d2' },
|
||||
sourceName: { type: 'string', example: 'Company Google Workspace' },
|
||||
startTime: { type: 'string', format: 'date-time' },
|
||||
duration: {
|
||||
type: 'integer',
|
||||
description: 'Duration in milliseconds.',
|
||||
example: 4500,
|
||||
},
|
||||
emailsProcessed: { type: 'integer', example: 120 },
|
||||
status: { type: 'string', example: 'completed' },
|
||||
},
|
||||
required: [
|
||||
'id',
|
||||
'sourceName',
|
||||
'startTime',
|
||||
'duration',
|
||||
'emailsProcessed',
|
||||
'status',
|
||||
],
|
||||
},
|
||||
IndexedInsights: {
|
||||
type: 'object',
|
||||
description: 'Insights derived from the search index.',
|
||||
properties: {
|
||||
topSenders: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sender: { type: 'string', example: 'finance@vendor.com' },
|
||||
count: { type: 'integer', example: 342 },
|
||||
},
|
||||
required: ['sender', 'count'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['topSenders'],
|
||||
},
|
||||
// --- Settings ---
|
||||
SystemSettings: {
|
||||
type: 'object',
|
||||
description: 'Non-sensitive system configuration values.',
|
||||
properties: {
|
||||
language: {
|
||||
type: 'string',
|
||||
enum: ['en', 'es', 'fr', 'de', 'it', 'pt', 'nl', 'ja', 'et', 'el'],
|
||||
example: 'en',
|
||||
description: 'Default UI language code.',
|
||||
},
|
||||
theme: {
|
||||
type: 'string',
|
||||
enum: ['light', 'dark', 'system'],
|
||||
example: 'system',
|
||||
description: 'Default color theme.',
|
||||
},
|
||||
supportEmail: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
nullable: true,
|
||||
example: 'support@example.com',
|
||||
description: 'Public-facing support email address.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Scan all route files for @openapi annotations
|
||||
apis: [resolve(__dirname, '../src/api/routes/*.ts')],
|
||||
};
|
||||
|
||||
const spec = swaggerJsdoc(options);
|
||||
|
||||
// Output to docs/ directory so VitePress can consume it
|
||||
const outputPath = resolve(__dirname, '../../../docs/api/openapi.json');
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, JSON.stringify(spec, null, 2));
|
||||
|
||||
console.log(`✅ OpenAPI spec generated: ${outputPath}`);
|
||||
console.log(` Paths: ${Object.keys(spec.paths ?? {}).length}, Tags: ${(spec.tags ?? []).length}`);
|
||||
81
packages/backend/src/api/controllers/api-key.controller.ts
Normal file
81
packages/backend/src/api/controllers/api-key.controller.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiKeyService } from '../../services/ApiKeyService';
|
||||
import { z } from 'zod';
|
||||
import { UserService } from '../../services/UserService';
|
||||
import { config } from '../../config';
|
||||
|
||||
const generateApiKeySchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'API kay name must be more than 1 characters')
|
||||
.max(255, 'API kay name must not be more than 255 characters'),
|
||||
expiresInDays: z
|
||||
.number()
|
||||
.int()
|
||||
.positive('Only positive number is allowed')
|
||||
.max(730, 'The API key must expire within 2 years / 730 days.'),
|
||||
});
|
||||
export class ApiKeyController {
|
||||
private userService = new UserService();
|
||||
public generateApiKey = async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
const { name, expiresInDays } = generateApiKeySchema.parse(req.body);
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const userId = req.user.sub;
|
||||
const actor = await this.userService.findById(userId);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const key = await ApiKeyService.generate(
|
||||
userId,
|
||||
name,
|
||||
expiresInDays,
|
||||
actor,
|
||||
req.ip || 'unknown'
|
||||
);
|
||||
|
||||
res.status(201).json({ key });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: req.t('api.requestBodyInvalid'), errors: error.message });
|
||||
}
|
||||
res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public getApiKeys = async (req: Request, res: Response) => {
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const userId = req.user.sub;
|
||||
const keys = await ApiKeyService.getKeys(userId);
|
||||
|
||||
res.status(200).json(keys);
|
||||
};
|
||||
|
||||
public deleteApiKey = async (req: Request, res: Response) => {
|
||||
if (config.app.isDemo) {
|
||||
return res.status(403).json({ message: req.t('errors.demoMode') });
|
||||
}
|
||||
const { id } = req.params;
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const userId = req.user.sub;
|
||||
const actor = await this.userService.findById(userId);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
await ApiKeyService.deleteKey(id, userId, actor, req.ip || 'unknown');
|
||||
|
||||
res.status(204).send({ message: req.t('apiKeys.deleteSuccess') });
|
||||
};
|
||||
}
|
||||
@@ -1,36 +1,99 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ArchivedEmailService } from '../../services/ArchivedEmailService';
|
||||
import { UserService } from '../../services/UserService';
|
||||
import { checkDeletionEnabled } from '../../helpers/deletionGuard';
|
||||
|
||||
export class ArchivedEmailController {
|
||||
public getArchivedEmails = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { ingestionSourceId } = req.params;
|
||||
const page = parseInt(req.query.page as string, 10) || 1;
|
||||
const limit = parseInt(req.query.limit as string, 10) || 10;
|
||||
private userService = new UserService();
|
||||
public getArchivedEmails = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { ingestionSourceId } = req.params;
|
||||
const page = parseInt(req.query.page as string, 10) || 1;
|
||||
const limit = parseInt(req.query.limit as string, 10) || 10;
|
||||
const userId = req.user?.sub;
|
||||
|
||||
const result = await ArchivedEmailService.getArchivedEmails(
|
||||
ingestionSourceId,
|
||||
page,
|
||||
limit
|
||||
);
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('Get archived emails error:', error);
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
}
|
||||
};
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
|
||||
public getArchivedEmailById = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const email = await ArchivedEmailService.getArchivedEmailById(id);
|
||||
if (!email) {
|
||||
return res.status(404).json({ message: 'Archived email not found' });
|
||||
}
|
||||
return res.status(200).json(email);
|
||||
} catch (error) {
|
||||
console.error(`Get archived email by id ${req.params.id} error:`, error);
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
}
|
||||
};
|
||||
const result = await ArchivedEmailService.getArchivedEmails(
|
||||
ingestionSourceId,
|
||||
page,
|
||||
limit,
|
||||
userId
|
||||
);
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('Get archived emails error:', error);
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public getArchivedEmailById = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user?.sub;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const actor = await this.userService.findById(userId);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
|
||||
const email = await ArchivedEmailService.getArchivedEmailById(
|
||||
id,
|
||||
userId,
|
||||
actor,
|
||||
req.ip || 'unknown'
|
||||
);
|
||||
if (!email) {
|
||||
return res.status(404).json({ message: req.t('archivedEmail.notFound') });
|
||||
}
|
||||
return res.status(200).json(email);
|
||||
} catch (error) {
|
||||
console.error(`Get archived email by id ${req.params.id} error:`, error);
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public deleteArchivedEmail = async (req: Request, res: Response): Promise<Response> => {
|
||||
// Guard: return 400 if deletion is disabled in system settings before touching anything else
|
||||
try {
|
||||
checkDeletionEnabled();
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
message: error instanceof Error ? error.message : req.t('errors.deletionDisabled'),
|
||||
});
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const userId = req.user?.sub;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const actor = await this.userService.findById(userId);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
|
||||
try {
|
||||
await ArchivedEmailService.deleteArchivedEmail(id, actor, req.ip || 'unknown');
|
||||
return res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error(`Delete archived email ${req.params.id} error:`, error);
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Archived email not found') {
|
||||
return res.status(404).json({ message: req.t('archivedEmail.notFound') });
|
||||
}
|
||||
// Retention policy / legal hold blocks are user-facing 400 errors
|
||||
if (error.message.startsWith('Deletion blocked by retention policy')) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
return res.status(500).json({ message: error.message });
|
||||
}
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,32 +1,130 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IAuthService } from '../../services/AuthService';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
import { UserService } from '../../services/UserService';
|
||||
import { IamService } from '../../services/IamService';
|
||||
import { db } from '../../database';
|
||||
import * as schema from '../../database/schema';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import 'dotenv/config';
|
||||
import { AuthorizationService } from '../../services/AuthorizationService';
|
||||
import { CaslPolicy } from '@open-archiver/types';
|
||||
|
||||
export class AuthController {
|
||||
#authService: IAuthService;
|
||||
#authService: AuthService;
|
||||
#userService: UserService;
|
||||
|
||||
constructor(authService: IAuthService) {
|
||||
this.#authService = authService;
|
||||
}
|
||||
constructor(authService: AuthService, userService: UserService) {
|
||||
this.#authService = authService;
|
||||
this.#userService = userService;
|
||||
}
|
||||
/**
|
||||
* Only used for setting up the instance, should only be displayed once upon instance set up.
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns
|
||||
*/
|
||||
public setup = async (req: Request, res: Response): Promise<Response> => {
|
||||
const { email, password, first_name, last_name } = req.body;
|
||||
|
||||
public login = async (req: Request, res: Response): Promise<Response> => {
|
||||
const { email, password } = req.body;
|
||||
if (!email || !password || !first_name || !last_name) {
|
||||
return res.status(400).json({ message: req.t('auth.setup.allFieldsRequired') });
|
||||
}
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ message: 'Email and password are required' });
|
||||
}
|
||||
try {
|
||||
const userCountResult = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.users);
|
||||
const userCount = Number(userCountResult[0].count);
|
||||
|
||||
try {
|
||||
const result = await this.#authService.login(email, password);
|
||||
if (userCount > 0) {
|
||||
return res.status(403).json({ message: req.t('auth.setup.alreadyCompleted') });
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return res.status(401).json({ message: 'Invalid credentials' });
|
||||
}
|
||||
const newUser = await this.#userService.createAdminUser(
|
||||
{ email, password, first_name, last_name },
|
||||
true
|
||||
);
|
||||
const result = await this.#authService.login(email, password, req.ip || 'unknown');
|
||||
return res.status(201).json(result);
|
||||
} catch (error) {
|
||||
console.error('Setup error:', error);
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
// In a real application, you'd want to log this error.
|
||||
console.error('Login error:', error);
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
}
|
||||
};
|
||||
public login = async (req: Request, res: Response): Promise<Response> => {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ message: req.t('auth.login.emailAndPasswordRequired') });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.#authService.login(email, password, req.ip || 'unknown');
|
||||
|
||||
if (!result) {
|
||||
return res.status(401).json({ message: req.t('auth.login.invalidCredentials') });
|
||||
}
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public status = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const users = await db.select().from(schema.users);
|
||||
|
||||
/**
|
||||
* Check the situation where the only user has "Super Admin" role, but they don't actually have Super Admin permission because the role was set up in an earlier version, we need to change that "Super Admin" role to the one used in the current version.
|
||||
*/
|
||||
if (users.length === 1) {
|
||||
const iamService = new IamService();
|
||||
const userRoles = await iamService.getRolesForUser(users[0].id);
|
||||
if (userRoles.some((r) => r.name === 'Super Admin')) {
|
||||
const authorizationService = new AuthorizationService();
|
||||
const hasAdminPermission = await authorizationService.can(
|
||||
users[0].id,
|
||||
'manage',
|
||||
'all'
|
||||
);
|
||||
if (!hasAdminPermission) {
|
||||
const suerAdminPolicies: CaslPolicy[] = [
|
||||
{
|
||||
action: 'manage',
|
||||
subject: 'all',
|
||||
},
|
||||
];
|
||||
await db
|
||||
.update(schema.roles)
|
||||
.set({
|
||||
policies: suerAdminPolicies,
|
||||
slug: 'predefined_super_admin',
|
||||
})
|
||||
.where(eq(schema.roles.name, 'Super Admin'));
|
||||
}
|
||||
}
|
||||
}
|
||||
// in case user uses older version with admin user variables, we will create the admin user using those variables.
|
||||
const needsSetupUser = users.length === 0;
|
||||
if (needsSetupUser && process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD) {
|
||||
await this.#userService.createAdminUser(
|
||||
{
|
||||
email: process.env.ADMIN_EMAIL,
|
||||
password: process.env.ADMIN_PASSWORD,
|
||||
first_name: 'Admin',
|
||||
last_name: 'User',
|
||||
},
|
||||
true
|
||||
);
|
||||
return res.status(200).json({ needsSetup: false });
|
||||
}
|
||||
return res.status(200).json({ needsSetup: needsSetupUser });
|
||||
} catch (error) {
|
||||
console.error('Status check error:', error);
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,30 +2,30 @@ import { Request, Response } from 'express';
|
||||
import { dashboardService } from '../../services/DashboardService';
|
||||
|
||||
class DashboardController {
|
||||
public async getStats(req: Request, res: Response) {
|
||||
const stats = await dashboardService.getStats();
|
||||
res.json(stats);
|
||||
}
|
||||
public async getStats(req: Request, res: Response) {
|
||||
const stats = await dashboardService.getStats();
|
||||
res.json(stats);
|
||||
}
|
||||
|
||||
public async getIngestionHistory(req: Request, res: Response) {
|
||||
const history = await dashboardService.getIngestionHistory();
|
||||
res.json(history);
|
||||
}
|
||||
public async getIngestionHistory(req: Request, res: Response) {
|
||||
const history = await dashboardService.getIngestionHistory();
|
||||
res.json(history);
|
||||
}
|
||||
|
||||
public async getIngestionSources(req: Request, res: Response) {
|
||||
const sources = await dashboardService.getIngestionSources();
|
||||
res.json(sources);
|
||||
}
|
||||
public async getIngestionSources(req: Request, res: Response) {
|
||||
const sources = await dashboardService.getIngestionSources();
|
||||
res.json(sources);
|
||||
}
|
||||
|
||||
public async getRecentSyncs(req: Request, res: Response) {
|
||||
const syncs = await dashboardService.getRecentSyncs();
|
||||
res.json(syncs);
|
||||
}
|
||||
public async getRecentSyncs(req: Request, res: Response) {
|
||||
const syncs = await dashboardService.getRecentSyncs();
|
||||
res.json(syncs);
|
||||
}
|
||||
|
||||
public async getIndexedInsights(req: Request, res: Response) {
|
||||
const insights = await dashboardService.getIndexedInsights();
|
||||
res.json(insights);
|
||||
}
|
||||
public async getIndexedInsights(req: Request, res: Response) {
|
||||
const insights = await dashboardService.getIndexedInsights();
|
||||
res.json(insights);
|
||||
}
|
||||
}
|
||||
|
||||
export const dashboardController = new DashboardController();
|
||||
|
||||
151
packages/backend/src/api/controllers/iam.controller.ts
Normal file
151
packages/backend/src/api/controllers/iam.controller.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { IamService } from '../../services/IamService';
|
||||
import { PolicyValidator } from '../../iam-policy/policy-validator';
|
||||
import type { CaslPolicy } from '@open-archiver/types';
|
||||
import { logger } from '../../config/logger';
|
||||
|
||||
export class IamController {
|
||||
#iamService: IamService;
|
||||
|
||||
constructor(iamService: IamService) {
|
||||
this.#iamService = iamService;
|
||||
}
|
||||
|
||||
public getRoles = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
let roles = await this.#iamService.getRoles();
|
||||
if (!roles.some((r) => r.slug?.includes('predefined_'))) {
|
||||
// create pre defined roles
|
||||
logger.info({}, 'Creating predefined roles');
|
||||
await this.createDefaultRoles();
|
||||
}
|
||||
res.status(200).json(roles);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: req.t('iam.failedToGetRoles') });
|
||||
}
|
||||
};
|
||||
|
||||
public getRoleById = async (req: Request, res: Response): Promise<void> => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const role = await this.#iamService.getRoleById(id);
|
||||
if (role) {
|
||||
res.status(200).json(role);
|
||||
} else {
|
||||
res.status(404).json({ message: req.t('iam.roleNotFound') });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: req.t('iam.failedToGetRole') });
|
||||
}
|
||||
};
|
||||
|
||||
public createRole = async (req: Request, res: Response) => {
|
||||
const { name, policies } = req.body;
|
||||
|
||||
if (!name || !policies) {
|
||||
res.status(400).json({ message: req.t('iam.missingRoleFields') });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const statement of policies) {
|
||||
const { valid, reason } = PolicyValidator.isValid(statement as CaslPolicy);
|
||||
if (!valid) {
|
||||
res.status(400).json({ message: `${req.t('iam.invalidPolicy')} ${reason}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const role = await this.#iamService.createRole(name, policies);
|
||||
res.status(201).json(role);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(500).json({ message: req.t('iam.failedToCreateRole') });
|
||||
}
|
||||
};
|
||||
|
||||
public deleteRole = async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
await this.#iamService.deleteRole(id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: req.t('iam.failedToDeleteRole') });
|
||||
}
|
||||
};
|
||||
|
||||
public updateRole = async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
const { name, policies } = req.body;
|
||||
|
||||
if (!name && !policies) {
|
||||
res.status(400).json({ message: req.t('iam.missingUpdateFields') });
|
||||
return;
|
||||
}
|
||||
|
||||
if (policies) {
|
||||
for (const statement of policies) {
|
||||
const { valid, reason } = PolicyValidator.isValid(statement as CaslPolicy);
|
||||
if (!valid) {
|
||||
res.status(400).json({ message: `${req.t('iam.invalidPolicy')} ${reason}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const role = await this.#iamService.updateRole(id, { name, policies });
|
||||
res.status(200).json(role);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: req.t('iam.failedToUpdateRole') });
|
||||
}
|
||||
};
|
||||
|
||||
private createDefaultRoles = async () => {
|
||||
try {
|
||||
// end user who can manage its own data, and create new ingestions.
|
||||
await this.#iamService.createRole(
|
||||
'End user',
|
||||
[
|
||||
{
|
||||
action: 'read',
|
||||
subject: 'dashboard',
|
||||
},
|
||||
{
|
||||
action: 'create',
|
||||
subject: 'ingestion',
|
||||
},
|
||||
{
|
||||
action: 'manage',
|
||||
subject: 'ingestion',
|
||||
conditions: {
|
||||
userId: '${user.id}',
|
||||
},
|
||||
},
|
||||
{
|
||||
action: 'manage',
|
||||
subject: 'archive',
|
||||
conditions: {
|
||||
'ingestionSource.userId': '${user.id}',
|
||||
},
|
||||
},
|
||||
],
|
||||
'predefined_end_user'
|
||||
);
|
||||
// read only
|
||||
await this.#iamService.createRole(
|
||||
'Read only',
|
||||
[
|
||||
{
|
||||
action: ['read', 'search'],
|
||||
subject: ['ingestion', 'archive', 'dashboard', 'users', 'roles'],
|
||||
},
|
||||
],
|
||||
'predefined_read_only_user'
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({}, 'Failed to create default roles');
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,97 +1,226 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { IngestionService } from '../../services/IngestionService';
|
||||
import { CreateIngestionSourceDto, UpdateIngestionSourceDto } from '@open-archiver/types';
|
||||
import {
|
||||
CreateIngestionSourceDto,
|
||||
UpdateIngestionSourceDto,
|
||||
IngestionSource,
|
||||
SafeIngestionSource,
|
||||
} from '@open-archiver/types';
|
||||
import { logger } from '../../config/logger';
|
||||
import { UserService } from '../../services/UserService';
|
||||
import { checkDeletionEnabled } from '../../helpers/deletionGuard';
|
||||
|
||||
export class IngestionController {
|
||||
public create = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const dto: CreateIngestionSourceDto = req.body;
|
||||
const newSource = await IngestionService.create(dto);
|
||||
return res.status(201).json(newSource);
|
||||
} catch (error) {
|
||||
console.error('Create ingestion source error:', error);
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
}
|
||||
};
|
||||
private userService = new UserService();
|
||||
/**
|
||||
* Converts an IngestionSource object to a safe version for client-side consumption
|
||||
* by removing the credentials.
|
||||
* @param source The full IngestionSource object.
|
||||
* @returns An object conforming to the SafeIngestionSource type.
|
||||
*/
|
||||
private toSafeIngestionSource(source: IngestionSource): SafeIngestionSource {
|
||||
const { credentials, ...safeSource } = source;
|
||||
return safeSource;
|
||||
}
|
||||
|
||||
public findAll = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const sources = await IngestionService.findAll();
|
||||
return res.status(200).json(sources);
|
||||
} catch (error) {
|
||||
console.error('Find all ingestion sources error:', error);
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
}
|
||||
};
|
||||
public create = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const dto: CreateIngestionSourceDto = req.body;
|
||||
const userId = req.user?.sub;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const actor = await this.userService.findById(userId);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const newSource = await IngestionService.create(
|
||||
dto,
|
||||
userId,
|
||||
actor,
|
||||
req.ip || 'unknown'
|
||||
);
|
||||
const safeSource = this.toSafeIngestionSource(newSource);
|
||||
return res.status(201).json(safeSource);
|
||||
} catch (error: any) {
|
||||
logger.error({ err: error }, 'Create ingestion source error');
|
||||
// Return a 400 Bad Request for connection errors
|
||||
return res.status(400).json({
|
||||
message: error.message || req.t('ingestion.failedToCreate'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
public findById = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const source = await IngestionService.findById(id);
|
||||
return res.status(200).json(source);
|
||||
} catch (error) {
|
||||
console.error(`Find ingestion source by id ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: error.message });
|
||||
}
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
}
|
||||
};
|
||||
public findAll = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const userId = req.user?.sub;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const sources = await IngestionService.findAll(userId);
|
||||
const safeSources = sources.map(this.toSafeIngestionSource);
|
||||
return res.status(200).json(safeSources);
|
||||
} catch (error) {
|
||||
console.error('Find all ingestion sources error:', error);
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public update = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const dto: UpdateIngestionSourceDto = req.body;
|
||||
const updatedSource = await IngestionService.update(id, dto);
|
||||
return res.status(200).json(updatedSource);
|
||||
} catch (error) {
|
||||
console.error(`Update ingestion source ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: error.message });
|
||||
}
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
}
|
||||
};
|
||||
public findById = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const source = await IngestionService.findById(id);
|
||||
const safeSource = this.toSafeIngestionSource(source);
|
||||
return res.status(200).json(safeSource);
|
||||
} catch (error) {
|
||||
console.error(`Find ingestion source by id ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: req.t('ingestion.notFound') });
|
||||
}
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public delete = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await IngestionService.delete(id);
|
||||
return res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error(`Delete ingestion source ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: error.message });
|
||||
}
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
}
|
||||
};
|
||||
public update = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const dto: UpdateIngestionSourceDto = req.body;
|
||||
const userId = req.user?.sub;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const actor = await this.userService.findById(userId);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const updatedSource = await IngestionService.update(
|
||||
id,
|
||||
dto,
|
||||
actor,
|
||||
req.ip || 'unknown'
|
||||
);
|
||||
const safeSource = this.toSafeIngestionSource(updatedSource);
|
||||
return res.status(200).json(safeSource);
|
||||
} catch (error) {
|
||||
console.error(`Update ingestion source ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: req.t('ingestion.notFound') });
|
||||
}
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public triggerInitialImport = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await IngestionService.triggerInitialImport(id);
|
||||
return res.status(202).json({ message: 'Initial import triggered successfully.' });
|
||||
} catch (error) {
|
||||
console.error(`Trigger initial import for ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: error.message });
|
||||
}
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
}
|
||||
};
|
||||
public delete = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
checkDeletionEnabled();
|
||||
const { id } = req.params;
|
||||
const userId = req.user?.sub;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const actor = await this.userService.findById(userId);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
await IngestionService.delete(id, actor, req.ip || 'unknown');
|
||||
return res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error(`Delete ingestion source ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: req.t('ingestion.notFound') });
|
||||
} else if (error instanceof Error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public pause = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updatedSource = await IngestionService.update(id, { status: 'paused' });
|
||||
return res.status(200).json(updatedSource);
|
||||
} catch (error) {
|
||||
console.error(`Pause ingestion source ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: error.message });
|
||||
}
|
||||
return res.status(500).json({ message: 'An internal server error occurred' });
|
||||
}
|
||||
};
|
||||
public triggerInitialImport = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await IngestionService.triggerInitialImport(id);
|
||||
return res.status(202).json({ message: req.t('ingestion.initialImportTriggered') });
|
||||
} catch (error) {
|
||||
console.error(`Trigger initial import for ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: req.t('ingestion.notFound') });
|
||||
}
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public pause = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user?.sub;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const actor = await this.userService.findById(userId);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const updatedSource = await IngestionService.update(
|
||||
id,
|
||||
{ status: 'paused' },
|
||||
actor,
|
||||
req.ip || 'unknown'
|
||||
);
|
||||
const safeSource = this.toSafeIngestionSource(updatedSource);
|
||||
return res.status(200).json(safeSource);
|
||||
} catch (error) {
|
||||
console.error(`Pause ingestion source ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: req.t('ingestion.notFound') });
|
||||
}
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public unmerge = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user?.sub;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const actor = await this.userService.findById(userId);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const updatedSource = await IngestionService.unmerge(id, actor, req.ip || 'unknown');
|
||||
const safeSource = this.toSafeIngestionSource(updatedSource);
|
||||
return res.status(200).json(safeSource);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, `Unmerge ingestion source ${req.params.id} error`);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: req.t('ingestion.notFound') });
|
||||
} else if (error instanceof Error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
|
||||
public triggerForceSync = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user?.sub;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
const actor = await this.userService.findById(userId);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
}
|
||||
await IngestionService.triggerForceSync(id, actor, req.ip || 'unknown');
|
||||
return res.status(202).json({ message: req.t('ingestion.forceSyncTriggered') });
|
||||
} catch (error) {
|
||||
console.error(`Trigger force sync for ${req.params.id} error:`, error);
|
||||
if (error instanceof Error && error.message === 'Ingestion source not found') {
|
||||
return res.status(404).json({ message: req.t('ingestion.notFound') });
|
||||
}
|
||||
return res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
29
packages/backend/src/api/controllers/integrity.controller.ts
Normal file
29
packages/backend/src/api/controllers/integrity.controller.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { IntegrityService } from '../../services/IntegrityService';
|
||||
import { z } from 'zod';
|
||||
|
||||
const checkIntegritySchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
|
||||
export class IntegrityController {
|
||||
private integrityService = new IntegrityService();
|
||||
|
||||
public checkIntegrity = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = checkIntegritySchema.parse(req.params);
|
||||
const results = await this.integrityService.checkEmailIntegrity(id);
|
||||
res.status(200).json(results);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: req.t('api.requestBodyInvalid'), errors: error.message });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Archived email not found') {
|
||||
return res.status(404).json({ message: req.t('errors.notFound') });
|
||||
}
|
||||
res.status(500).json({ message: req.t('errors.internalServerError') });
|
||||
}
|
||||
};
|
||||
}
|
||||
42
packages/backend/src/api/controllers/jobs.controller.ts
Normal file
42
packages/backend/src/api/controllers/jobs.controller.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { JobsService } from '../../services/JobsService';
|
||||
import {
|
||||
IGetQueueJobsRequestParams,
|
||||
IGetQueueJobsRequestQuery,
|
||||
JobStatus,
|
||||
} from '@open-archiver/types';
|
||||
|
||||
export class JobsController {
|
||||
private jobsService: JobsService;
|
||||
|
||||
constructor() {
|
||||
this.jobsService = new JobsService();
|
||||
}
|
||||
|
||||
public getQueues = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const queues = await this.jobsService.getQueues();
|
||||
res.status(200).json({ queues });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Error fetching queues', error });
|
||||
}
|
||||
};
|
||||
|
||||
public getQueueJobs = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { queueName } = req.params as unknown as IGetQueueJobsRequestParams;
|
||||
const { status, page, limit } = req.query as unknown as IGetQueueJobsRequestQuery;
|
||||
const pageNumber = parseInt(page, 10) || 1;
|
||||
const limitNumber = parseInt(limit, 10) || 10;
|
||||
const queueDetails = await this.jobsService.getQueueDetails(
|
||||
queueName,
|
||||
status,
|
||||
pageNumber,
|
||||
limitNumber
|
||||
);
|
||||
res.status(200).json(queueDetails);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: 'Error fetching queue jobs', error });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -3,32 +3,42 @@ import { SearchService } from '../../services/SearchService';
|
||||
import { MatchingStrategies } from 'meilisearch';
|
||||
|
||||
export class SearchController {
|
||||
private searchService: SearchService;
|
||||
private searchService: SearchService;
|
||||
|
||||
constructor() {
|
||||
this.searchService = new SearchService();
|
||||
}
|
||||
constructor() {
|
||||
this.searchService = new SearchService();
|
||||
}
|
||||
|
||||
public search = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { keywords, page, limit, matchingStrategy } = req.query;
|
||||
public search = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { keywords, page, limit, matchingStrategy } = req.query;
|
||||
const userId = req.user?.sub;
|
||||
|
||||
if (!keywords) {
|
||||
res.status(400).json({ message: 'Keywords are required' });
|
||||
return;
|
||||
}
|
||||
if (!userId) {
|
||||
res.status(401).json({ message: req.t('errors.unauthorized') });
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await this.searchService.searchEmails({
|
||||
query: keywords as string,
|
||||
page: page ? parseInt(page as string) : 1,
|
||||
limit: limit ? parseInt(limit as string) : 10,
|
||||
matchingStrategy: matchingStrategy as MatchingStrategies
|
||||
});
|
||||
if (!keywords) {
|
||||
res.status(400).json({ message: req.t('search.keywordsRequired') });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json(results);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'An unknown error occurred';
|
||||
res.status(500).json({ message });
|
||||
}
|
||||
};
|
||||
const results = await this.searchService.searchEmails(
|
||||
{
|
||||
query: keywords as string,
|
||||
page: page ? parseInt(page as string) : 1,
|
||||
limit: limit ? parseInt(limit as string) : 10,
|
||||
matchingStrategy: matchingStrategy as MatchingStrategies,
|
||||
},
|
||||
userId,
|
||||
req.ip || 'unknown'
|
||||
);
|
||||
|
||||
res.status(200).json(results);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : req.t('errors.unknown');
|
||||
res.status(500).json({ message });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
38
packages/backend/src/api/controllers/settings.controller.ts
Normal file
38
packages/backend/src/api/controllers/settings.controller.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { SettingsService } from '../../services/SettingsService';
|
||||
import { UserService } from '../../services/UserService';
|
||||
|
||||
const settingsService = new SettingsService();
|
||||
const userService = new UserService();
|
||||
|
||||
export const getSystemSettings = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const settings = await settingsService.getSystemSettings();
|
||||
res.status(200).json(settings);
|
||||
} catch (error) {
|
||||
// A more specific error could be logged here
|
||||
res.status(500).json({ message: req.t('settings.failedToRetrieve') });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSystemSettings = async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Basic validation can be performed here if necessary
|
||||
if (!req.user || !req.user.sub) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const actor = await userService.findById(req.user.sub);
|
||||
if (!actor) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
const updatedSettings = await settingsService.updateSystemSettings(
|
||||
req.body,
|
||||
actor,
|
||||
req.ip || 'unknown'
|
||||
);
|
||||
res.status(200).json(updatedSettings);
|
||||
} catch (error) {
|
||||
// A more specific error could be logged here
|
||||
res.status(500).json({ message: req.t('settings.failedToUpdate') });
|
||||
}
|
||||
};
|
||||
@@ -1,32 +1,50 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { StorageService } from '../../services/StorageService';
|
||||
import * as path from 'path';
|
||||
import { storage as storageConfig } from '../../config/storage';
|
||||
|
||||
export class StorageController {
|
||||
constructor(private storageService: StorageService) { }
|
||||
constructor(private storageService: StorageService) {}
|
||||
|
||||
public downloadFile = async (req: Request, res: Response): Promise<void> => {
|
||||
const filePath = req.query.path as string;
|
||||
public downloadFile = async (req: Request, res: Response): Promise<void> => {
|
||||
const unsafePath = req.query.path as string;
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).send('File path is required');
|
||||
return;
|
||||
}
|
||||
if (!unsafePath) {
|
||||
res.status(400).send(req.t('storage.filePathRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileExists = await this.storageService.exists(filePath);
|
||||
if (!fileExists) {
|
||||
console.log(filePath);
|
||||
res.status(404).send('File not found');
|
||||
return;
|
||||
}
|
||||
// Normalize the path to prevent directory traversal
|
||||
const normalizedPath = path.normalize(unsafePath).replace(/^(\.\.(\/|\\|$))+/, '');
|
||||
|
||||
const fileStream = await this.storageService.get(filePath);
|
||||
const fileName = filePath.split('/').pop();
|
||||
res.setHeader('Content-Disposition', `attachment; filename=${fileName}`);
|
||||
fileStream.pipe(res);
|
||||
} catch (error) {
|
||||
console.error('Error downloading file:', error);
|
||||
res.status(500).send('Error downloading file');
|
||||
}
|
||||
};
|
||||
// Determine the base path from storage configuration
|
||||
const basePath = storageConfig.type === 'local' ? storageConfig.rootPath : '/';
|
||||
|
||||
// Resolve the full path and ensure it's within the storage directory
|
||||
const fullPath = path.join(basePath, normalizedPath);
|
||||
|
||||
if (!fullPath.startsWith(basePath)) {
|
||||
res.status(400).send(req.t('storage.invalidFilePath'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the sanitized, relative path for storage service operations
|
||||
const safePath = path.relative(basePath, fullPath);
|
||||
|
||||
try {
|
||||
const fileExists = await this.storageService.exists(safePath);
|
||||
if (!fileExists) {
|
||||
res.status(404).send(req.t('storage.fileNotFound'));
|
||||
return;
|
||||
}
|
||||
|
||||
const fileStream = await this.storageService.get(safePath);
|
||||
const fileName = path.basename(safePath);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||
fileStream.pipe(res);
|
||||
} catch (error) {
|
||||
console.error('Error downloading file:', error);
|
||||
res.status(500).send(req.t('storage.downloadError'));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user