Compare commits

...

52 Commits
v0.2.0 ... wip

Author SHA1 Message Date
Wayne
3b16223caa Merge branch 'main' into wip 2025-08-24 16:09:31 +02:00
Wayne
19c26a8c24 fix storage legend overflow 2025-08-24 16:04:12 +02:00
Wayne
3c6254504b Fix storage chart legend overflow 2025-08-24 16:02:58 +02:00
Wei S.
a2c55f36ee Cla v2 (#68)
* Format checked, contributing.md update

* Middleware setup

* IAP API, create user/roles in frontend

* RBAC using CASL library

* Switch to CASL, secure search, resource-level access control

* Remove inherent behavior, index userEmail, adding docs for IAM policies

* Format

* CLA v2

* cla-v2

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-08-24 15:03:05 +02:00
Wayne
9873ab263a Merge branch 'main' into CLA-v2 2025-08-24 15:02:13 +02:00
Wei S.
9fdba4cd61 Role based access: Adding docs to docs site (#67)
* Format checked, contributing.md update

* Middleware setup

* IAP API, create user/roles in frontend

* RBAC using CASL library

* Switch to CASL, secure search, resource-level access control

* Remove inherent behavior, index userEmail, adding docs for IAM policies

* Format

* Adding IAM policy documentation to Docs site

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-08-24 14:52:08 +02:00
Wayne
c2cfd96b6f cla-v2 2025-08-24 14:07:25 +02:00
Wei S.
108c646596 CLA-v2
CLA-v2: Clarifying LogicLabs OÜ is the entity contributors are signing the agreement with.
2025-08-24 15:05:15 +03:00
Wei S.
61e44c81f7 Role based access (#61)
* Format checked, contributing.md update

* Middleware setup

* IAP API, create user/roles in frontend

* RBAC using CASL library

* Switch to CASL, secure search, resource-level access control

* Remove inherent behavior, index userEmail, adding docs for IAM policies

* Format

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-08-23 23:19:51 +03:00
Wayne
a5478c416a CLA v2 2025-08-23 20:37:24 +03:00
Wayne
32c016dbfe Resolve conflict 2025-08-22 13:51:33 +03:00
Wayne
317f034c56 Format 2025-08-22 13:47:48 +03:00
Wayne
faadc2fad6 Remove inherent behavior, index userEmail, adding docs for IAM policies 2025-08-22 13:43:00 +03:00
Wei S.
f651aeab0e Role based access (#60)
* Format checked, contributing.md update

* Middleware setup

* IAP API, create user/roles in frontend

* RBAC using CASL library

* Switch to CASL, secure search, resource-level access control

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-08-22 00:51:56 +03:00
Wayne
3ab76f5c2d Fix: fix old "Super Admin" role in existing db 2025-08-22 00:47:51 +03:00
Wei S.
3fb4290934 Role based access (#59)
* Format checked, contributing.md update

* Middleware setup

* IAP API, create user/roles in frontend

* RBAC using CASL library

* Switch to CASL, secure search, resource-level access control

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-08-21 23:53:21 +03:00
Wayne
5b5bb019fc Merge branch 'main' into role-based-access 2025-08-21 23:52:26 +03:00
Wei S.
8c33b63bdf feat: Role based access control (#58)
* Format checked, contributing.md update

* Middleware setup

* IAP API, create user/roles in frontend

* RBAC using CASL library

* Switch to CASL, secure search, resource-level access control

---------

Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-08-21 23:45:06 +03:00
Wayne
db38dde86f Switch to CASL, secure search, resource-level access control 2025-08-21 23:39:02 +03:00
Wayne
d81abc657b RBAC using CASL library 2025-08-20 01:08:51 +03:00
David Girón
2b325f3461 feat: optimize Dockerfile (#47)
* define base image arg

* create base stage with common content

* chmod executable entrypoint file

this avoids re-copying the same file as is being modified in the docker
layer

* cache npm downloaded packages

avoids re-downloading deps if cache content is available
2025-08-19 12:17:32 +03:00
Wayne
720160a3d8 IAP API, create user/roles in frontend 2025-08-19 11:20:30 +03:00
Til Wegener
4d3c164bc0 Fix UI size display and ingestion history graph (#50)
* fix: unify size display, improve graph interpolation & time readability

* fix display human-readable sizes in ingestion chart

* display human-readable sizes in ingestion chart

* fix: format code

* fix keep fallback for item.name
2025-08-19 11:06:31 +03:00
Wayne
2987f159dd Middleware setup 2025-08-18 14:09:02 +03:00
Wei S.
7288286fd9 Format checked, contributing.md update (#49)
Co-authored-by: Wayne <5291640+ringoinca@users.noreply.github.com>
2025-08-17 17:42:49 +03:00
Wayne
47324f76ea Merge branch 'main' into dev 2025-08-17 17:40:55 +03:00
Wayne
5f8d201726 Format checked, contributing.md update 2025-08-17 17:38:16 +03:00
Wei S.
ec1cf3cf0b Merge pull request #46 from axeldunkel/fix/imap-sync-skipping-emails
Fix IMAP sync marking all emails as synced before fetching
2025-08-17 17:28:31 +03:00
axeldunkel
9c9152a2ee Merge branch 'LogicLabs-OU:main' into fix/imap-sync-skipping-emails 2025-08-17 13:41:12 +02:00
Axel Dunkel
c05b3b92d9 fix the indentation, to use tabs not spaces 2025-08-17 11:34:21 +00:00
Wei S.
aed0c964c8 Merge pull request #48 from tilwegener/fix/graph-delta-query-removed-field
Fix Graph delta query: exclude unsupported @removed field
2025-08-17 13:34:37 +03:00
Til Wegener
86dda6c6d3 Fix Graph delta query: exclude unsupported @removed field 2025-08-17 09:58:17 +00:00
Axel Dunkel
6e1dd17267 Fix IMAP sync marking all emails as synced before fetching
Initialize newMaxUids with lastUid instead of mailbox maximum
to prevent marking unfetched emails as synced.

The bug sets newMaxUids to the highest UID before fetching,
causing all existing emails to be skipped when sync state
is saved early.

Fixes #45
2025-08-16 08:43:09 +00:00
Wei S.
b4d2125020 Merge pull request #43 from LogicLabs-OU/dev
Fix pnpm-lock unmatch error
2025-08-15 14:24:33 +03:00
Wayne
a2ca79d3eb Fix pnpm-lock unmatch error 2025-08-15 14:23:53 +03:00
Wei S.
8f519dc995 Merge pull request #42 from LogicLabs-OU/dev
1. Project-wide format using prettier
2. Delete single archived emails
3. Handle attachment indexing error gracefully
2025-08-15 14:20:11 +03:00
Wayne
b2ca3ef0e1 Project wide format 2025-08-15 14:18:23 +03:00
Wayne
9873228d01 Before format 2025-08-15 14:14:01 +03:00
Wei S.
94190f8b7c Merge pull request #41 from LogicLabs-OU/dev
Dev: project wide formatting setup
2025-08-15 13:46:46 +03:00
Wayne
832e29bd92 Project prettier setup 2025-08-15 13:45:58 +03:00
Wei S.
cba6dfcae1 Merge pull request #36 from tilwegener/feat/delete-mail-button
feat: delete archived emails + improve IMAP UID and PDF parsing

Note: Will do project-wide formatting in the next commit, merging this PR.
2025-08-15 13:45:31 +03:00
Til Wegener
24f5b341a8 Merge branch 'LogicLabs-OU:main' into feat/delete-mail-button 2025-08-14 10:13:16 +02:00
Til Wegener
cba7e05d98 fix: handle attachment cleanup errors safely and surface messages 2025-08-14 08:10:58 +00:00
Til Wegener
cfdfe42fb8 fix(email-deletion): redirect to archived list and purge search index 2025-08-14 07:25:12 +00:00
Til Wegener
9138c1c753 feat: remove archived emails and related data 2025-08-14 06:26:51 +00:00
Til Wegener
c4afa471cb chore: log IMAP message UID during processing 2025-08-13 19:24:34 +00:00
Til Wegener
187282c68d fix: handle gaps in IMAP UID ranges 2025-08-13 19:24:05 +00:00
Wayne
82a83a71e4 BODY_SIZE_LIMIT fix, database url encode 2025-08-13 21:55:22 +03:00
Til Wegener
ff676ecb86 * avoid hanging when pdf2json fails by resolving text extraction with an empty string 2025-08-13 20:11:47 +02:00
Wei S.
9ff6801afc Merge pull request #33 from LogicLabs-OU/dev
Increase file upload limit and improve ingestion robustness
2025-08-13 20:55:35 +03:00
Wayne
d2b4337be9 Fix error when pst file emails don't include senders 2025-08-13 20:46:02 +03:00
Wayne
b03791d9a6 adding FRONTEND_BODY_SIZE_LIMIT to allow bigger file upload for the frontend. This is to fix the pst file upload error. 2025-08-13 19:20:19 +03:00
204 changed files with 23487 additions and 19254 deletions

View File

@@ -33,6 +33,8 @@ REDIS_TLS_ENABLED=false
# --- Storage Settings ---
# Choose your storage backend. Valid options are 'local' or 's3'.
STORAGE_TYPE=local
# The maximum request body size to accept in bytes including while streaming. The body size can also be specified with a unit suffix for kilobytes (K), megabytes (M), or gigabytes (G). For example, 512K or 1M. Defaults to 512kb. Or the value of Infinity if you don't want any upload limit.
BODY_SIZE_LIMIT=100M
# --- Local Storage Settings ---
# The path inside the container where files will be stored.

27
.github/CLA-v2.md vendored Normal file
View 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.

View File

@@ -23,8 +23,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
with:
path-to-signatures: 'signatures/version1/cla.json'
path-to-document: 'https://github.com/LogicLabs-OU/OpenArchiver/tree/main/.github/CLA.md'
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'

13
.prettierignore Normal file
View 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

View File

@@ -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",

View File

@@ -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

View File

@@ -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
@@ -41,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`

View File

@@ -40,38 +40,37 @@ Password: openarchiver_demo
## ✨ Key Features
- **Universal Ingestion**: Connect to any email provider to perform initial bulk imports and maintain continuous, real-time synchronization. Ingestion sources include:
- **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
- IMAP connection
- Google Workspace
- Microsoft 365
- PST files
- Zipped .eml files
- **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.).
- **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).
- **Comprehensive Auditing**: An immutable audit trail logs all system activities, ensuring you have a clear record of who accessed what and when (TBD).
- **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.).
- **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).
- **Comprehensive Auditing**: An immutable audit trail logs all system activities, ensuring you have a clear record of who accessed what and when (TBD).
## 🛠️ 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. (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
- **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
## 📦 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 4GB of RAM (2GB of RAM if you use external Postgres, Redis (Valkey) and Meilisearch instances).
- [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
@@ -106,17 +105,17 @@ Open Archiver is built on a modern, scalable, and maintainable technology stack:
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)
- [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!
- **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.

View File

@@ -1,21 +1,29 @@
# Dockerfile for 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/
# 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. Use --shamefully-hoist to create a flat node_modules structure
RUN pnpm install --shamefully-hoist --frozen-lockfile --prod=false
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 . .
@@ -24,20 +32,8 @@ COPY . .
RUN pnpm build
# 2. Production Stage: Install only production dependencies and copy built artifacts
FROM node:22-alpine AS production
WORKDIR /app
FROM base AS production
# 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 production dependencies
# RUN pnpm install --shamefully-hoist --frozen-lockfile --prod=true
# Copy built application from build stage
COPY --from=build /app/packages/backend/dist ./packages/backend/dist
@@ -48,7 +44,6 @@ COPY --from=build /app/packages/backend/src/database/migrations ./packages/backe
# Copy the entrypoint script and make it executable
COPY docker/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose the port the app runs on
EXPOSE 4000

0
docker/docker-entrypoint.sh Normal file → Executable file
View File

View File

@@ -1,71 +1,86 @@
import { defineConfig } from 'vitepress';
export default defineConfig({
head: [
[
'script',
{
defer: '',
src: 'https://analytics.zenceipt.com/script.js',
'data-website-id': '2c8b452e-eab5-4f82-8ead-902d8f8b976f'
}
]
],
title: 'Open Archiver',
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 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: 'API Reference',
items: [
{ text: 'Overview', link: '/api/' },
{ text: 'Authentication', link: '/api/authentication' },
{ text: 'Auth', link: '/api/auth' },
{ text: 'Archived Email', link: '/api/archived-email' },
{ text: 'Dashboard', link: '/api/dashboard' },
{ text: 'Ingestion', link: '/api/ingestion' },
{ text: 'Search', link: '/api/search' },
{ text: 'Storage', link: '/api/storage' }
]
},
{
text: 'Services',
items: [
{ text: 'Overview', link: '/services/' },
{ text: 'Storage Service', link: '/services/storage-service' }
]
}
]
}
head: [
[
'script',
{
defer: '',
src: 'https://analytics.zenceipt.com/script.js',
'data-website-id': '2c8b452e-eab5-4f82-8ead-902d8f8b976f',
},
],
['link', { rel: 'icon', href: '/logo-sq.svg' }],
],
title: 'Open Archiver',
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 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: 'API Reference',
items: [
{ text: 'Overview', link: '/api/' },
{ text: 'Authentication', link: '/api/authentication' },
{ text: 'Auth', link: '/api/auth' },
{ text: 'Archived Email', link: '/api/archived-email' },
{ text: 'Dashboard', link: '/api/dashboard' },
{ text: 'Ingestion', link: '/api/ingestion' },
{ text: 'Search', link: '/api/search' },
{ text: 'Storage', link: '/api/storage' },
],
},
{
text: 'Services',
items: [
{ text: 'Overview', link: '/services/' },
{ text: 'Storage Service', link: '/services/storage-service' },
{
text: 'IAM Service', items: [
{ text: 'IAM Policies', link: '/services/iam-service/iam-policy' }
]
},
],
},
],
},
});

View File

@@ -2,16 +2,16 @@
## 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)
- [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)
- [api](api/index.md)
- [Ingestion Sources API Documentation](api/ingestion.md)
- [services](services/index.md)
- [Pluggable Storage Service (StorageService)](services/storage-service.md)

View File

@@ -27,29 +27,27 @@ Retrieves a paginated list of archived emails for a specific ingestion source.
#### Responses
- **200 OK:** A paginated list of archived emails.
- **200 OK:** A paginated list of archived emails.
```json
{
"items": [
{
"id": "email-id",
"subject": "Test Email",
"from": "sender@example.com",
"sentAt": "2023-10-27T10:00:00.000Z",
"hasAttachments": true,
"recipients": [
{ "name": "Recipient 1", "email": "recipient1@example.com" }
]
}
],
"total": 100,
"page": 1,
"limit": 10
"items": [
{
"id": "email-id",
"subject": "Test Email",
"from": "sender@example.com",
"sentAt": "2023-10-27T10:00:00.000Z",
"hasAttachments": true,
"recipients": [{ "name": "Recipient 1", "email": "recipient1@example.com" }]
}
],
"total": 100,
"page": 1,
"limit": 10
}
```
- **500 Internal Server Error:** An unexpected error occurred.
- **500 Internal Server Error:** An unexpected error occurred.
### GET /api/v1/archived-emails/:id
@@ -65,32 +63,30 @@ Retrieves a single archived email by its ID, including its raw content and attac
#### Responses
- **200 OK:** The archived email details.
- **200 OK:** The archived email details.
```json
{
"id": "email-id",
"subject": "Test Email",
"from": "sender@example.com",
"sentAt": "2023-10-27T10:00:00.000Z",
"hasAttachments": true,
"recipients": [
{ "name": "Recipient 1", "email": "recipient1@example.com" }
],
"raw": "...",
"attachments": [
{
"id": "attachment-id",
"filename": "document.pdf",
"mimeType": "application/pdf",
"sizeBytes": 12345
}
]
"id": "email-id",
"subject": "Test Email",
"from": "sender@example.com",
"sentAt": "2023-10-27T10:00:00.000Z",
"hasAttachments": true,
"recipients": [{ "name": "Recipient 1", "email": "recipient1@example.com" }],
"raw": "...",
"attachments": [
{
"id": "attachment-id",
"filename": "document.pdf",
"mimeType": "application/pdf",
"sizeBytes": 12345
}
]
}
```
- **404 Not Found:** The archived email with the specified ID was not found.
- **500 Internal Server Error:** An unexpected error occurred.
- **404 Not Found:** The archived email with the specified ID was not found.
- **500 Internal Server Error:** An unexpected error occurred.
## Service Methods
@@ -98,14 +94,14 @@ Retrieves a single archived email by its ID, including its raw content and attac
Retrieves a paginated list of archived emails from the database for a given ingestion source.
- **ingestionSourceId:** The ID of the ingestion source.
- **page:** The page number for pagination.
- **limit:** The number of items per page.
- **Returns:** A promise that resolves to a `PaginatedArchivedEmails` object.
- **ingestionSourceId:** The ID of the ingestion source.
- **page:** The page number for pagination.
- **limit:** The number of items per page.
- **Returns:** A promise that resolves to a `PaginatedArchivedEmails` object.
### `getArchivedEmailById(emailId: string): Promise<ArchivedEmail | null>`
Retrieves a single archived email by its ID, including its raw content and attachments.
- **emailId:** The ID of the archived email.
- **Returns:** A promise that resolves to an `ArchivedEmail` object or `null` if not found.
- **emailId:** The ID of the archived email.
- **Returns:** A promise that resolves to an `ArchivedEmail` object or `null` if not found.

View File

@@ -21,40 +21,40 @@ Authenticates a user and returns a JWT if the credentials are valid.
#### Responses
- **200 OK:** Authentication successful.
- **200 OK:** Authentication successful.
```json
{
"accessToken": "your.jwt.token",
"user": {
"id": "user-id",
"email": "user@example.com",
"role": "user"
}
"accessToken": "your.jwt.token",
"user": {
"id": "user-id",
"email": "user@example.com",
"role": "user"
}
}
```
- **400 Bad Request:** Email or password not provided.
- **400 Bad Request:** Email or password not provided.
```json
{
"message": "Email and password are required"
"message": "Email and password are required"
}
```
- **401 Unauthorized:** Invalid credentials.
- **401 Unauthorized:** Invalid credentials.
```json
{
"message": "Invalid credentials"
"message": "Invalid credentials"
}
```
- **500 Internal Server Error:** An unexpected error occurred.
- **500 Internal Server Error:** An unexpected error occurred.
```json
{
"message": "An internal server error occurred"
"message": "An internal server error occurred"
}
```
@@ -64,21 +64,21 @@ Authenticates a user and returns a JWT if the credentials are valid.
Compares a plain-text password with a hashed password to verify its correctness.
- **password:** The plain-text password.
- **hash:** The hashed password to compare against.
- **Returns:** A promise that resolves to `true` if the password is valid, otherwise `false`.
- **password:** The plain-text password.
- **hash:** The hashed password to compare against.
- **Returns:** A promise that resolves to `true` if the password is valid, otherwise `false`.
### `login(email: string, password: string): Promise<LoginResponse | null>`
Handles the user login process. It finds the user by email, verifies the password, and generates a JWT upon successful authentication.
- **email:** The user's email.
- **password:** The user's password.
- **Returns:** A promise that resolves to a `LoginResponse` object containing the `accessToken` and `user` details, or `null` if authentication fails.
- **email:** The user's email.
- **password:** The user's password.
- **Returns:** A promise that resolves to a `LoginResponse` object containing the `accessToken` and `user` details, or `null` if authentication fails.
### `verifyToken(token: string): Promise<AuthTokenPayload | null>`
Verifies the authenticity and expiration of a JWT.
- **token:** The JWT string to verify.
- **Returns:** A promise that resolves to the token's `AuthTokenPayload` if valid, otherwise `null`.
- **token:** The JWT string to verify.
- **Returns:** A promise that resolves to the token's `AuthTokenPayload` if valid, otherwise `null`.

View File

@@ -22,12 +22,12 @@ Content-Type: application/json
```json
{
"accessToken": "your.jwt.token",
"user": {
"id": "user-id",
"email": "user@example.com",
"role": "user"
}
"accessToken": "your.jwt.token",
"user": {
"id": "user-id",
"email": "user@example.com",
"role": "user"
}
}
```

View File

@@ -14,13 +14,13 @@ Retrieves overall statistics, including the total number of archived emails, tot
#### Responses
- **200 OK:** An object containing the dashboard statistics.
- **200 OK:** An object containing the dashboard statistics.
```json
{
"totalEmailsArchived": 12345,
"totalStorageUsed": 54321098,
"failedIngestionsLast7Days": 3
"totalEmailsArchived": 12345,
"totalStorageUsed": 54321098,
"failedIngestionsLast7Days": 3
}
```
@@ -32,20 +32,20 @@ Retrieves the email ingestion history for the last 30 days, grouped by day.
#### Responses
- **200 OK:** An object containing the ingestion history.
- **200 OK:** An object containing the ingestion history.
```json
{
"history": [
{
"date": "2023-09-27T00:00:00.000Z",
"count": 150
},
{
"date": "2023-09-28T00:00:00.000Z",
"count": 200
}
]
"history": [
{
"date": "2023-09-27T00:00:00.000Z",
"count": 150
},
{
"date": "2023-09-28T00:00:00.000Z",
"count": 200
}
]
}
```
@@ -57,24 +57,24 @@ Retrieves a list of all ingestion sources along with their status and storage us
#### Responses
- **200 OK:** An array of ingestion source objects.
- **200 OK:** An array of ingestion source objects.
```json
[
{
"id": "source-id-1",
"name": "Google Workspace",
"provider": "google",
"status": "active",
"storageUsed": 12345678
},
{
"id": "source-id-2",
"name": "Microsoft 365",
"provider": "microsoft",
"status": "error",
"storageUsed": 87654321
}
{
"id": "source-id-1",
"name": "Google Workspace",
"provider": "google",
"status": "active",
"storageUsed": 12345678
},
{
"id": "source-id-2",
"name": "Microsoft 365",
"provider": "microsoft",
"status": "error",
"storageUsed": 87654321
}
]
```
@@ -86,7 +86,7 @@ Retrieves a list of recent synchronization jobs. (Note: This is currently a plac
#### Responses
- **200 OK:** An empty array.
- **200 OK:** An empty array.
```json
[]
@@ -100,15 +100,15 @@ Retrieves insights from the indexed email data, such as the top senders.
#### Responses
- **200 OK:** An object containing indexed insights.
- **200 OK:** An object containing indexed insights.
```json
{
"topSenders": [
{
"sender": "user@example.com",
"count": 42
}
]
"topSenders": [
{
"sender": "user@example.com",
"count": 42
}
]
}
```

View File

@@ -10,9 +10,9 @@ Before making requests to protected endpoints, you must authenticate with the AP
## 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.
- [**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.

View File

@@ -18,16 +18,16 @@ The request body should be a `CreateIngestionSourceDto` object.
```typescript
interface CreateIngestionSourceDto {
name: string;
provider: 'google' | 'microsoft' | 'generic_imap';
providerConfig: IngestionCredentials;
name: string;
provider: 'google' | 'microsoft' | 'generic_imap';
providerConfig: IngestionCredentials;
}
```
#### Responses
- **201 Created:** The newly created ingestion source.
- **500 Internal Server Error:** An unexpected error occurred.
- **201 Created:** The newly created ingestion source.
- **500 Internal Server Error:** An unexpected error occurred.
### GET /api/v1/ingestion-sources
@@ -37,8 +37,8 @@ Retrieves all ingestion sources.
#### Responses
- **200 OK:** An array of ingestion source objects.
- **500 Internal Server Error:** An unexpected error occurred.
- **200 OK:** An array of ingestion source objects.
- **500 Internal Server Error:** An unexpected error occurred.
### GET /api/v1/ingestion-sources/:id
@@ -54,9 +54,9 @@ Retrieves a single ingestion source by its ID.
#### Responses
- **200 OK:** The ingestion source object.
- **404 Not Found:** Ingestion source not found.
- **500 Internal Server Error:** An unexpected error occurred.
- **200 OK:** The ingestion source object.
- **404 Not Found:** Ingestion source not found.
- **500 Internal Server Error:** An unexpected error occurred.
### PUT /api/v1/ingestion-sources/:id
@@ -76,24 +76,18 @@ The request body should be an `UpdateIngestionSourceDto` object.
```typescript
interface UpdateIngestionSourceDto {
name?: string;
provider?: 'google' | 'microsoft' | 'generic_imap';
providerConfig?: IngestionCredentials;
status?:
| 'pending_auth'
| 'auth_success'
| 'importing'
| 'active'
| 'paused'
| 'error';
name?: string;
provider?: 'google' | 'microsoft' | 'generic_imap';
providerConfig?: IngestionCredentials;
status?: 'pending_auth' | 'auth_success' | 'importing' | 'active' | 'paused' | 'error';
}
```
#### Responses
- **200 OK:** The updated ingestion source object.
- **404 Not Found:** Ingestion source not found.
- **500 Internal Server Error:** An unexpected error occurred.
- **200 OK:** The updated ingestion source object.
- **404 Not Found:** Ingestion source not found.
- **500 Internal Server Error:** An unexpected error occurred.
### DELETE /api/v1/ingestion-sources/:id
@@ -109,9 +103,9 @@ Deletes an ingestion source and all associated data.
#### Responses
- **204 No Content:** The ingestion source was deleted successfully.
- **404 Not Found:** Ingestion source not found.
- **500 Internal Server Error:** An unexpected error occurred.
- **204 No Content:** The ingestion source was deleted successfully.
- **404 Not Found:** Ingestion source not found.
- **500 Internal Server Error:** An unexpected error occurred.
### POST /api/v1/ingestion-sources/:id/import
@@ -127,9 +121,9 @@ Triggers the initial import process for an ingestion source.
#### Responses
- **202 Accepted:** The initial import was triggered successfully.
- **404 Not Found:** Ingestion source not found.
- **500 Internal Server Error:** An unexpected error occurred.
- **202 Accepted:** The initial import was triggered successfully.
- **404 Not Found:** Ingestion source not found.
- **500 Internal Server Error:** An unexpected error occurred.
### POST /api/v1/ingestion-sources/:id/pause
@@ -145,9 +139,9 @@ Pauses an active ingestion source.
#### Responses
- **200 OK:** The updated ingestion source object with a `paused` status.
- **404 Not Found:** Ingestion source not found.
- **500 Internal Server Error:** An unexpected error occurred.
- **200 OK:** The updated ingestion source object with a `paused` status.
- **404 Not Found:** Ingestion source not found.
- **500 Internal Server Error:** An unexpected error occurred.
### POST /api/v1/ingestion-sources/:id/sync
@@ -163,6 +157,6 @@ Triggers a forced synchronization for an ingestion source.
#### Responses
- **202 Accepted:** The force sync was triggered successfully.
- **404 Not Found:** Ingestion source not found.
- **500 Internal Server Error:** An unexpected error occurred.
- **202 Accepted:** The force sync was triggered successfully.
- **404 Not Found:** Ingestion source not found.
- **500 Internal Server Error:** An unexpected error occurred.

View File

@@ -24,27 +24,27 @@ Performs a search query against the indexed emails.
#### Responses
- **200 OK:** A search result object.
- **200 OK:** A search result object.
```json
{
"hits": [
{
"id": "email-id",
"subject": "Test Email",
"from": "sender@example.com",
"_formatted": {
"subject": "<em>Test</em> Email"
}
}
],
"total": 1,
"page": 1,
"limit": 10,
"totalPages": 1,
"processingTimeMs": 5
"hits": [
{
"id": "email-id",
"subject": "Test Email",
"from": "sender@example.com",
"_formatted": {
"subject": "<em>Test</em> Email"
}
}
],
"total": 1,
"page": 1,
"limit": 10,
"totalPages": 1,
"processingTimeMs": 5
}
```
- **400 Bad Request:** Keywords are required.
- **500 Internal Server Error:** An unexpected error occurred.
- **400 Bad Request:** Keywords are required.
- **500 Internal Server Error:** An unexpected error occurred.

View File

@@ -20,7 +20,7 @@ Downloads a file from the storage.
#### Responses
- **200 OK:** The file stream.
- **400 Bad Request:** File path is required or invalid.
- **404 Not Found:** File not found.
- **500 Internal Server Error:** An unexpected error occurred.
- **200 OK:** The file stream.
- **400 Bad Request:** File path is required or invalid.
- **404 Not Found:** File not found.
- **500 Internal Server Error:** An unexpected error occurred.

View File

@@ -10,33 +10,33 @@ Open Archiver provides a robust, self-hosted solution for archiving, storing, 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).
- **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)
- [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)
- [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.
- **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.

View File

@@ -1,141 +0,0 @@
# IAM Policies Guide
This document provides a comprehensive guide to the Identity and Access Management (IAM) policies in Open Archiver. Our policy structure is inspired by AWS IAM, providing a powerful and flexible way to manage permissions.
## 1. Policy Structure
A policy is a JSON object that consists of one or more statements. Each statement includes an `Effect`, `Action`, and `Resource`.
```json
{
"Effect": "Allow",
"Action": ["archive:read", "archive:search"],
"Resource": ["archive/all"]
}
```
- **`Effect`**: Specifies whether the statement results in an `Allow` or `Deny`. An explicit `Deny` always overrides an `Allow`.
- **`Action`**: A list of operations that the policy grants or denies permission to perform. Actions are formatted as `service:operation`.
- **`Resource`**: A list of resources to which the actions apply. Resources are specified in a hierarchical format. Wildcards (`*`) can be used.
## 2. Wildcard Support
Our IAM system supports wildcards (`*`) in both `Action` and `Resource` fields to provide flexible permission management, as defined in the `PolicyValidator`.
### Action Wildcards
You can use wildcards to grant broad permissions for actions:
- **Global Wildcard (`*`)**: A standalone `*` in the `Action` field grants permission for all possible actions across all services.
```json
"Action": ["*"]
```
- **Service-Level Wildcard (`service:*`)**: A wildcard at the end of an action string grants permission for all actions within that specific service.
```json
"Action": ["archive:*"]
```
### Resource Wildcards
Wildcards can also be used to specify resources:
- **Global Wildcard (`*`)**: A standalone `*` in the `Resource` field applies the policy to all resources in the system.
```json
"Resource": ["*"]
```
- **Partial Wildcards**: Some services allow wildcards at specific points in the resource path to refer to all resources of a certain type. For example, to target all ingestion sources:
```json
"Resource": ["ingestion-source/*"]
```
## 3. Actions and Resources by Service
The following sections define the available actions and resources, categorized by their respective services.
### Service: `archive`
The `archive` service pertains to all actions related to accessing and managing archived emails.
**Actions:**
| Action | Description |
| :--------------- | :--------------------------------------------------------------------- |
| `archive:read` | Grants permission to read the content and metadata of archived emails. |
| `archive:search` | Grants permission to perform search queries against the email archive. |
| `archive:export` | Grants permission to export search results or individual emails. |
**Resources:**
| Resource | Description |
| :------------------------------------ | :--------------------------------------------------------------------------------------- |
| `archive/all` | Represents the entire email archive. |
| `archive/ingestion-source/{sourceId}` | Scopes the action to emails from a specific ingestion source. |
| `archive/mailbox/{email}` | Scopes the action to a single, specific mailbox, usually identified by an email address. |
| `archive/custodian/{custodianId}` | Scopes the action to emails belonging to a specific custodian. |
---
### Service: `ingestion`
The `ingestion` service covers the management of email ingestion sources.
**Actions:**
| Action | Description |
| :----------------------- | :--------------------------------------------------------------------------- |
| `ingestion:createSource` | Grants permission to create a new ingestion source. |
| `ingestion:readSource` | Grants permission to view the details of ingestion sources. |
| `ingestion:updateSource` | Grants permission to modify the configuration of an ingestion source. |
| `ingestion:deleteSource` | Grants permission to delete an ingestion source. |
| `ingestion:manageSync` | Grants permission to trigger, pause, or force a sync on an ingestion source. |
**Resources:**
| Resource | Description |
| :---------------------------- | :-------------------------------------------------------- |
| `ingestion-source/*` | Represents all ingestion sources. |
| `ingestion-source/{sourceId}` | Scopes the action to a single, specific ingestion source. |
---
### Service: `system`
The `system` service is for managing system-level settings, users, and roles.
**Actions:**
| Action | Description |
| :---------------------- | :-------------------------------------------------- |
| `system:readSettings` | Grants permission to view system settings. |
| `system:updateSettings` | Grants permission to modify system settings. |
| `system:readUsers` | Grants permission to list and view user accounts. |
| `system:createUser` | Grants permission to create new user accounts. |
| `system:updateUser` | Grants permission to modify existing user accounts. |
| `system:deleteUser` | Grants permission to delete user accounts. |
| `system:assignRole` | Grants permission to assign roles to users. |
**Resources:**
| Resource | Description |
| :--------------------- | :---------------------------------------------------- |
| `system/settings` | Represents the system configuration. |
| `system/users` | Represents all user accounts within the system. |
| `system/user/{userId}` | Scopes the action to a single, specific user account. |
---
### Service: `dashboard`
The `dashboard` service relates to viewing analytics and overview information.
**Actions:**
| Action | Description |
| :--------------- | :-------------------------------------------------------------- |
| `dashboard:read` | Grants permission to view all dashboard widgets and statistics. |
**Resources:**
| Resource | Description |
| :------------ | :------------------------------------------ |
| `dashboard/*` | Represents all components of the dashboard. |

View File

@@ -0,0 +1,289 @@
# IAM Policies
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"
}
}
]
```

View 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"
}
}
]
```

View File

@@ -1,2 +1 @@
# services

View File

@@ -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.

View File

@@ -6,8 +6,8 @@ OpenArchiver allows you to import EML files from a zip archive. This is useful f
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.
- **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:

View File

@@ -6,8 +6,8 @@ The connection uses a **Google Cloud Service Account** with **Domain-Wide Delega
## 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.
- 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
@@ -24,30 +24,27 @@ The setup process involves three main parts:
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").
- 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**
- 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"**.
- 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.
- 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
@@ -60,14 +57,14 @@ 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._
- `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.
- 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.
---
@@ -77,25 +74,23 @@ To resolve this, you must have **Organization Administrator** permissions.
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).
- 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"**.
- 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:
- 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"**.
- Click **"Authorize"**.
The service account is now permitted to list users and read their email data across your domain.
@@ -112,11 +107,10 @@ Finally, you will provide the generated credentials to the application.
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.
- **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"**.

View File

@@ -12,18 +12,17 @@ This guide will walk you through connecting a standard IMAP email account as an
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".
- **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.
- **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`.
- **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`.
- **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.
- **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.
- **Password:** Enter the password for your email account.
4. **Save Changes:**
Once you have filled in all the details, click the **"Save changes"** button.
@@ -41,9 +40,9 @@ Please consult your email provider's documentation to see if they support app pa
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"**.
- 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
@@ -51,17 +50,17 @@ Please consult your email provider's documentation to see if they support app pa
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"**.
- 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.
- **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.

View File

@@ -4,8 +4,8 @@ Open Archiver can connect to a variety of email sources to ingest and archive yo
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)
- [Google Workspace](./google-workspace.md)
- [Microsoft 365](./microsoft-365.md)
- [Generic IMAP Server](./imap.md)
- [EML Import](./eml.md)
- [PST Import](./pst.md)

View File

@@ -6,7 +6,7 @@ The connection uses the **Microsoft Graph API** and an **App Registration** in M
## Prerequisites
- You must have one of the following administrator roles in your Microsoft 365 tenant: **Global Administrator**, **Application Administrator**, or **Cloud Application Administrator**.
- You must have one of the following administrator roles in your Microsoft 365 tenant: **Global Administrator**, **Application Administrator**, or **Cloud Application Administrator**.
## Setup Overview
@@ -27,9 +27,9 @@ First, you will create an "App registration," which acts as an identity for the
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.
- **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.
---
@@ -43,8 +43,8 @@ Next, you must grant the application the specific permissions required to read u
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`
- `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.
@@ -57,8 +57,8 @@ The client secret is a password that the archiving service will use to authentic
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.
- **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.
@@ -75,12 +75,11 @@ You now have the three pieces of information required to configure the connectio
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.
- **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"**.

View File

@@ -6,8 +6,8 @@ OpenArchiver allows you to import PST files. This is useful for importing emails
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.
- **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

View File

@@ -4,9 +4,9 @@ This guide will walk you through setting up Open Archiver using Docker Compose.
## 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.
- [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
@@ -33,11 +33,11 @@ Now, open the `.env` file in a text editor and customize the settings.
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:
- `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
```
@@ -65,11 +65,12 @@ 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` |
| 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` |
| `SYNC_FREQUENCY` | The frequency of continuous email syncing. See [cron syntax](https://crontab.guru/) for more details. | `* * * * *` |
#### Docker Compose Service Configuration
@@ -90,16 +91,17 @@ These variables are used by `docker-compose.yml` to configure the services.
#### Storage Settings
| Variable | Description | Default Value |
| ------------------------------ | ------------------------------------------------------------------------------------- | ------------------------- |
| `STORAGE_TYPE` | The storage backend to use (`local` or `s3`). | `local` |
| `STORAGE_LOCAL_ROOT_PATH` | The root path for local file storage. | `/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` |
| 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 local file storage. | `/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` |
#### Security & Authentication
@@ -120,9 +122,9 @@ 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.
- 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:
@@ -140,9 +142,9 @@ You can log in with the `ADMIN_EMAIL` and `ADMIN_PASSWORD` you configured in you
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)
- [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
@@ -172,9 +174,8 @@ To do this, you will need to make a small modification to your `docker-compose.y
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.
- 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:

View File

@@ -1,36 +1,41 @@
{
"name": "open-archiver",
"private": true,
"scripts": {
"dev": "dotenv -- pnpm --filter \"./packages/*\" --parallel dev",
"build": "pnpm --filter \"./packages/*\" 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": "concurrently \"pnpm start:workers\" \"pnpm start\"",
"docs:dev": "vitepress dev docs --port 3009",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"
},
"dependencies": {
"concurrently": "^9.2.0",
"dotenv-cli": "8.0.0"
},
"devDependencies": {
"typescript": "5.8.3",
"vitepress": "^1.6.3"
},
"packageManager": "pnpm@10.13.1",
"engines": {
"node": ">=22.0.0",
"pnpm": "10.13.1"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
}
"name": "open-archiver",
"private": true,
"scripts": {
"dev": "dotenv -- pnpm --filter \"./packages/*\" --parallel dev",
"build": "pnpm --filter \"./packages/*\" 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": "concurrently \"pnpm start:workers\" \"pnpm start\"",
"docs:dev": "vitepress dev docs --port 3009",
"docs:build": "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": {
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.6.14",
"typescript": "5.8.3",
"vitepress": "^1.6.4"
},
"packageManager": "pnpm@10.13.1",
"engines": {
"node": ">=22.0.0",
"pnpm": "10.13.1"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
}
}

View File

@@ -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,
});

View File

@@ -1,75 +1,76 @@
{
"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": {
"@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:*",
"archiver": "^7.0.1",
"axios": "^1.10.0",
"bcryptjs": "^3.0.2",
"bullmq": "^5.56.3",
"busboy": "^1.6.0",
"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",
"imapflow": "^1.0.191",
"jose": "^6.0.11",
"mailparser": "^3.7.4",
"mammoth": "^1.9.1",
"meilisearch": "^0.51.0",
"multer": "^2.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",
"tsconfig-paths": "^4.2.0",
"xlsx": "^0.18.5",
"yauzl": "^3.2.0"
},
"devDependencies": {
"@bull-board/api": "^6.11.0",
"@bull-board/express": "^6.11.0",
"@types/archiver": "^6.0.3",
"@types/busboy": "^1.5.4",
"@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/yauzl": "^2.10.3",
"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,
"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": {
"@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",
"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",
"imapflow": "^1.0.191",
"jose": "^6.0.11",
"mailparser": "^3.7.4",
"mammoth": "^1.9.1",
"meilisearch": "^0.51.0",
"multer": "^2.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",
"tsconfig-paths": "^4.2.0",
"xlsx": "^0.18.5",
"yauzl": "^3.2.0"
},
"devDependencies": {
"@bull-board/api": "^6.11.0",
"@bull-board/express": "^6.11.0",
"@types/archiver": "^6.0.3",
"@types/busboy": "^1.5.4",
"@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/yauzl": "^2.10.3",
"bull-board": "^2.1.3",
"ts-node-dev": "^2.0.0",
"typescript": "^5.8.3"
}
}

View File

@@ -1,36 +1,69 @@
import { Request, Response } from 'express';
import { ArchivedEmailService } from '../../services/ArchivedEmailService';
import { config } from '../../config';
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;
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: '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: 'An internal server error occurred' });
}
};
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: 'Unauthorized' });
}
const email = await ArchivedEmailService.getArchivedEmailById(id, userId);
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' });
}
};
public deleteArchivedEmail = async (req: Request, res: Response): Promise<Response> => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
try {
const { id } = req.params;
await ArchivedEmailService.deleteArchivedEmail(id);
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: error.message });
}
return res.status(500).json({ message: error.message });
}
return res.status(500).json({ message: 'An internal server error occurred' });
}
};
}

View File

@@ -1,93 +1,130 @@
import type { Request, Response } from 'express';
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 { sql } from 'drizzle-orm';
import { eq, sql } from 'drizzle-orm';
import 'dotenv/config';
import { AuthorizationService } from '../../services/AuthorizationService';
import { CaslPolicy } from '@open-archiver/types';
export class AuthController {
#authService: AuthService;
#userService: UserService;
#authService: AuthService;
#userService: UserService;
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;
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;
if (!email || !password || !first_name || !last_name) {
return res.status(400).json({ message: 'Email, password, and name are required' });
}
if (!email || !password || !first_name || !last_name) {
return res.status(400).json({ message: 'Email, password, and name are required' });
}
try {
const userCountResult = await db.select({ count: sql<number>`count(*)` }).from(schema.users);
const userCount = Number(userCountResult[0].count);
try {
const userCountResult = await db
.select({ count: sql<number>`count(*)` })
.from(schema.users);
const userCount = Number(userCountResult[0].count);
if (userCount > 0) {
return res.status(403).json({ message: 'Setup has already been completed.' });
}
if (userCount > 0) {
return res.status(403).json({ message: 'Setup has already been completed.' });
}
const newUser = await this.#userService.createAdminUser({ email, password, first_name, last_name }, true);
const result = await this.#authService.login(email, password);
return res.status(201).json(result);
} catch (error) {
console.error('Setup error:', error);
return res.status(500).json({ message: 'An internal server error occurred' });
}
};
const newUser = await this.#userService.createAdminUser(
{ email, password, first_name, last_name },
true
);
const result = await this.#authService.login(email, password);
return res.status(201).json(result);
} catch (error) {
console.error('Setup 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;
public login = async (req: Request, res: Response): Promise<Response> => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ message: 'Email and password are required' });
}
if (!email || !password) {
return res.status(400).json({ message: 'Email and password are required' });
}
try {
const result = await this.#authService.login(email, password);
try {
const result = await this.#authService.login(email, password);
if (!result) {
return res.status(401).json({ message: 'Invalid credentials' });
}
if (!result) {
return res.status(401).json({ message: 'Invalid credentials' });
}
return res.status(200).json(result);
} catch (error) {
console.error('Login error:', error);
return res.status(500).json({ message: 'An internal server error occurred' });
}
};
return res.status(200).json(result);
} catch (error) {
console.error('Login error:', error);
return res.status(500).json({ message: 'An internal server error occurred' });
}
};
public status = async (req: Request, res: Response): Promise<Response> => {
try {
public status = async (req: Request, res: Response): Promise<Response> => {
try {
const users = await db.select().from(schema.users);
const userCountResult = await db.select({ count: sql<number>`count(*)` }).from(schema.users);
const userCount = Number(userCountResult[0].count);
const needsSetup = userCount === 0;
// in case user uses older version with admin user variables, we will create the admin user using those variables.
if (needsSetup && 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 });
} catch (error) {
console.error('Status check error:', error);
return res.status(500).json({ message: 'An internal server error occurred' });
}
};
/**
* 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({ needsSetupUser });
} catch (error) {
console.error('Status check error:', error);
return res.status(500).json({ message: 'An internal server error occurred' });
}
};
}

View File

@@ -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();

View File

@@ -1,71 +1,161 @@
import { Request, Response } from 'express';
import { IamService } from '../../services/IamService';
import { PolicyValidator } from '../../iam-policy/policy-validator';
import type { PolicyStatement } from '@open-archiver/types';
import type { CaslPolicy } from '@open-archiver/types';
import { logger } from '../../config/logger';
import { config } from '../../config';
export class IamController {
#iamService: IamService;
#iamService: IamService;
constructor(iamService: IamService) {
this.#iamService = iamService;
}
constructor(iamService: IamService) {
this.#iamService = iamService;
}
public getRoles = async (req: Request, res: Response): Promise<void> => {
try {
const roles = await this.#iamService.getRoles();
res.status(200).json(roles);
} catch (error) {
res.status(500).json({ error: 'Failed to get roles.' });
}
};
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: 'Failed to get roles.' });
}
};
public getRoleById = async (req: Request, res: Response): Promise<void> => {
const { id } = req.params;
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({ error: 'Role not found.' });
}
} catch (error) {
res.status(500).json({ error: 'Failed to get role.' });
}
};
try {
const role = await this.#iamService.getRoleById(id);
if (role) {
res.status(200).json(role);
} else {
res.status(404).json({ message: 'Role not found.' });
}
} catch (error) {
res.status(500).json({ message: 'Failed to get role.' });
}
};
public createRole = async (req: Request, res: Response): Promise<void> => {
const { name, policy } = req.body;
public createRole = async (req: Request, res: Response) => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
const { name, policies } = req.body;
if (!name || !policy) {
res.status(400).json({ error: 'Missing required fields: name and policy.' });
return;
}
if (!name || !policies) {
res.status(400).json({ message: 'Missing required fields: name and policy.' });
return;
}
for (const statement of policy) {
const { valid, reason } = PolicyValidator.isValid(statement as PolicyStatement);
if (!valid) {
res.status(400).json({ error: `Invalid policy statement: ${reason}` });
return;
}
}
try {
for (const statement of policies) {
const { valid, reason } = PolicyValidator.isValid(statement as CaslPolicy);
if (!valid) {
res.status(400).json({ message: `Invalid policy statement: ${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: 'Failed to create role.' });
}
};
try {
const role = await this.#iamService.createRole(name, policy);
res.status(201).json(role);
} catch (error) {
res.status(500).json({ error: 'Failed to create role.' });
}
};
public deleteRole = async (req: Request, res: Response) => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
const { id } = req.params;
public deleteRole = async (req: Request, res: Response): Promise<void> => {
const { id } = req.params;
try {
await this.#iamService.deleteRole(id);
res.status(204).send();
} catch (error) {
res.status(500).json({ message: 'Failed to delete role.' });
}
};
try {
await this.#iamService.deleteRole(id);
res.status(204).send();
} catch (error) {
res.status(500).json({ error: 'Failed to delete role.' });
}
};
public updateRole = async (req: Request, res: Response) => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
const { id } = req.params;
const { name, policies } = req.body;
if (!name && !policies) {
res.status(400).json({ message: 'Missing fields to update: name or policies.' });
return;
}
if (policies) {
for (const statement of policies) {
const { valid, reason } = PolicyValidator.isValid(statement as CaslPolicy);
if (!valid) {
res.status(400).json({ message: `Invalid policy statement: ${reason}` });
return;
}
}
}
try {
const role = await this.#iamService.updateRole(id, { name, policies });
res.status(200).json(role);
} catch (error) {
res.status(500).json({ message: 'Failed to update role.' });
}
};
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');
}
};
}

View File

@@ -1,153 +1,164 @@
import { Request, Response } from 'express';
import { IngestionService } from '../../services/IngestionService';
import {
CreateIngestionSourceDto,
UpdateIngestionSourceDto,
IngestionSource,
SafeIngestionSource
CreateIngestionSourceDto,
UpdateIngestionSourceDto,
IngestionSource,
SafeIngestionSource,
} from '@open-archiver/types';
import { logger } from '../../config/logger';
import { config } from '../../config';
export class IngestionController {
/**
* 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;
}
/**
* 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 create = async (req: Request, res: Response): Promise<Response> => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
try {
const dto: CreateIngestionSourceDto = req.body;
const newSource = await IngestionService.create(dto);
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 || 'Failed to create ingestion source due to a connection error.' });
}
};
public create = async (req: Request, res: Response): Promise<Response> => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
try {
const dto: CreateIngestionSourceDto = req.body;
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ message: 'Unauthorized' });
}
const newSource = await IngestionService.create(dto, userId);
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 || 'Failed to create ingestion source due to a connection error.',
});
}
};
public findAll = async (req: Request, res: Response): Promise<Response> => {
try {
const sources = await IngestionService.findAll();
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: '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: '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: '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: 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: error.message });
}
return res.status(500).json({ message: 'An internal server error occurred' });
}
};
public update = async (req: Request, res: Response): Promise<Response> => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
try {
const { id } = req.params;
const dto: UpdateIngestionSourceDto = req.body;
const updatedSource = await IngestionService.update(id, dto);
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: error.message });
}
return res.status(500).json({ message: 'An internal server error occurred' });
}
};
public update = async (req: Request, res: Response): Promise<Response> => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
try {
const { id } = req.params;
const dto: UpdateIngestionSourceDto = req.body;
const updatedSource = await IngestionService.update(id, dto);
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: error.message });
}
return res.status(500).json({ message: 'An internal server error occurred' });
}
};
public delete = async (req: Request, res: Response): Promise<Response> => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
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 delete = async (req: Request, res: Response): Promise<Response> => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
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 triggerInitialImport = async (req: Request, res: Response): Promise<Response> => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
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 triggerInitialImport = async (req: Request, res: Response): Promise<Response> => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
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 pause = async (req: Request, res: Response): Promise<Response> => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
try {
const { id } = req.params;
const updatedSource = await IngestionService.update(id, { status: 'paused' });
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: error.message });
}
return res.status(500).json({ message: 'An internal server error occurred' });
}
};
public pause = async (req: Request, res: Response): Promise<Response> => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
try {
const { id } = req.params;
const updatedSource = await IngestionService.update(id, { status: 'paused' });
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: error.message });
}
return res.status(500).json({ message: 'An internal server error occurred' });
}
};
public triggerForceSync = async (req: Request, res: Response): Promise<Response> => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
try {
const { id } = req.params;
await IngestionService.triggerForceSync(id);
return res.status(202).json({ message: 'Force sync triggered successfully.' });
} 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: error.message });
}
return res.status(500).json({ message: 'An internal server error occurred' });
}
};
public triggerForceSync = async (req: Request, res: Response): Promise<Response> => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
try {
const { id } = req.params;
await IngestionService.triggerForceSync(id);
return res.status(202).json({ message: 'Force sync triggered successfully.' });
} 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: error.message });
}
return res.status(500).json({ message: 'An internal server error occurred' });
}
};
}

View File

@@ -3,32 +3,41 @@ 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: '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: 'Keywords are required' });
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
);
res.status(200).json(results);
} catch (error) {
const message = error instanceof Error ? error.message : 'An unknown error occurred';
res.status(500).json({ message });
}
};
}

View File

@@ -4,47 +4,47 @@ 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 unsafePath = req.query.path as string;
public downloadFile = async (req: Request, res: Response): Promise<void> => {
const unsafePath = req.query.path as string;
if (!unsafePath) {
res.status(400).send('File path is required');
return;
}
if (!unsafePath) {
res.status(400).send('File path is required');
return;
}
// Normalize the path to prevent directory traversal
const normalizedPath = path.normalize(unsafePath).replace(/^(\.\.(\/|\\|$))+/, '');
// Normalize the path to prevent directory traversal
const normalizedPath = path.normalize(unsafePath).replace(/^(\.\.(\/|\\|$))+/, '');
// Determine the base path from storage configuration
const basePath = storageConfig.type === 'local' ? storageConfig.rootPath : '/';
// 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);
// 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('Invalid file path');
return;
}
if (!fullPath.startsWith(basePath)) {
res.status(400).send('Invalid file path');
return;
}
// Use the sanitized, relative path for storage service operations
const safePath = path.relative(basePath, fullPath);
// 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('File not found');
return;
}
try {
const fileExists = await this.storageService.exists(safePath);
if (!fileExists) {
res.status(404).send('File not found');
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('Error downloading file');
}
};
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('Error downloading file');
}
};
}

View File

@@ -4,23 +4,22 @@ import { randomUUID } from 'crypto';
import busboy from 'busboy';
import { config } from '../../config/index';
export const uploadFile = async (req: Request, res: Response) => {
const storage = new StorageService();
const bb = busboy({ headers: req.headers });
let filePath = '';
let originalFilename = '';
const storage = new StorageService();
const bb = busboy({ headers: req.headers });
let filePath = '';
let originalFilename = '';
bb.on('file', (fieldname, file, filename) => {
originalFilename = filename.filename;
const uuid = randomUUID();
filePath = `${config.storage.openArchiverFolderName}/tmp/${uuid}-${originalFilename}`;
storage.put(filePath, file);
});
bb.on('file', (fieldname, file, filename) => {
originalFilename = filename.filename;
const uuid = randomUUID();
filePath = `${config.storage.openArchiverFolderName}/tmp/${uuid}-${originalFilename}`;
storage.put(filePath, file);
});
bb.on('finish', () => {
res.json({ filePath });
});
bb.on('finish', () => {
res.json({ filePath });
});
req.pipe(bb);
req.pipe(bb);
};

View File

@@ -0,0 +1,66 @@
import { Request, Response } from 'express';
import { UserService } from '../../services/UserService';
import * as schema from '../../database/schema';
import { sql } from 'drizzle-orm';
import { db } from '../../database';
import { config } from '../../config';
const userService = new UserService();
export const getUsers = async (req: Request, res: Response) => {
const users = await userService.findAll();
res.json(users);
};
export const getUser = async (req: Request, res: Response) => {
const user = await userService.findById(req.params.id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.json(user);
};
export const createUser = async (req: Request, res: Response) => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
const { email, first_name, last_name, password, roleId } = req.body;
const newUser = await userService.createUser(
{ email, first_name, last_name, password },
roleId
);
res.status(201).json(newUser);
};
export const updateUser = async (req: Request, res: Response) => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
const { email, first_name, last_name, roleId } = req.body;
const updatedUser = await userService.updateUser(
req.params.id,
{ email, first_name, last_name },
roleId
);
if (!updatedUser) {
return res.status(404).json({ message: 'User not found' });
}
res.json(updatedUser);
};
export const deleteUser = async (req: Request, res: Response) => {
if (config.app.isDemo) {
return res.status(403).json({ message: 'This operation is not allowed in demo mode.' });
}
const userCountResult = await db.select({ count: sql<number>`count(*)` }).from(schema.users);
console.log('iusercount,', userCountResult[0].count);
const isOnlyUser = Number(userCountResult[0].count) === 1;
if (isOnlyUser) {
return res.status(400).json({
message: 'You are trying to delete the only user in the database, this is not allowed.',
});
}
await userService.deleteUser(req.params.id);
res.status(204).send();
};

View File

@@ -2,9 +2,9 @@ import rateLimit from 'express-rate-limit';
// Rate limiter to prevent brute-force attacks on the login endpoint
export const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // Limit each IP to 10 login requests per windowMs
message: 'Too many login attempts from this IP, please try again after 15 minutes',
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // Limit each IP to 10 login requests per windowMs
message: 'Too many login attempts from this IP, please try again after 15 minutes',
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
});

View File

@@ -5,35 +5,37 @@ import 'dotenv/config';
// By using module augmentation, we can add our custom 'user' property
// to the Express Request interface in a type-safe way.
declare global {
namespace Express {
export interface Request {
user?: AuthTokenPayload;
}
}
namespace Express {
export interface Request {
user?: AuthTokenPayload;
}
}
}
export const requireAuth = (authService: AuthService) => {
return async (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Unauthorized: No token provided' });
}
const token = authHeader.split(' ')[1];
try {
// use a SUPER_API_KEY for all authentications. add process.env.SUPER_API_KEY conditional check in case user didn't set a SUPER_API_KEY.
if (process.env.SUPER_API_KEY && token === process.env.SUPER_API_KEY) {
next();
return;
}
const payload = await authService.verifyToken(token);
if (!payload) {
return res.status(401).json({ message: 'Unauthorized: Invalid token' });
}
req.user = payload;
next();
} catch (error) {
console.error('Authentication error:', error);
return res.status(500).json({ message: 'An internal server error occurred during authentication' });
}
};
return async (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Unauthorized: No token provided' });
}
const token = authHeader.split(' ')[1];
try {
// use a SUPER_API_KEY for all authentications. add process.env.SUPER_API_KEY conditional check in case user didn't set a SUPER_API_KEY.
if (process.env.SUPER_API_KEY && token === process.env.SUPER_API_KEY) {
next();
return;
}
const payload = await authService.verifyToken(token);
if (!payload) {
return res.status(401).json({ message: 'Unauthorized: Invalid token' });
}
req.user = payload;
next();
} catch (error) {
console.error('Authentication error:', error);
return res
.status(500)
.json({ message: 'An internal server error occurred during authentication' });
}
};
};

View File

@@ -0,0 +1,36 @@
import { AuthorizationService } from '../../services/AuthorizationService';
import type { Request, Response, NextFunction } from 'express';
import { AppActions, AppSubjects } from '@open-archiver/types';
export const requirePermission = (
action: AppActions,
subjectName: AppSubjects,
rejectMessage?: string
) => {
return async (req: Request, res: Response, next: NextFunction) => {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ message: 'Unauthorized' });
}
let resourceObject = undefined;
// Logic to fetch resourceObject if needed for condition-based checks...
const authorizationService = new AuthorizationService();
const hasPermission = await authorizationService.can(
userId,
action,
subjectName,
resourceObject
);
if (!hasPermission) {
return res.status(403).json({
message:
rejectMessage || `You don't have the permission to perform the current action.`,
});
}
next();
};
};

View File

@@ -1,20 +1,35 @@
import { Router } from 'express';
import { ArchivedEmailController } from '../controllers/archived-email.controller';
import { requireAuth } from '../middleware/requireAuth';
import { requirePermission } from '../middleware/requirePermission';
import { AuthService } from '../../services/AuthService';
export const createArchivedEmailRouter = (
archivedEmailController: ArchivedEmailController,
authService: AuthService
archivedEmailController: ArchivedEmailController,
authService: AuthService
): Router => {
const router = Router();
const router = Router();
// Secure all routes in this module
router.use(requireAuth(authService));
// Secure all routes in this module
router.use(requireAuth(authService));
router.get('/ingestion-source/:ingestionSourceId', archivedEmailController.getArchivedEmails);
router.get(
'/ingestion-source/:ingestionSourceId',
requirePermission('read', 'archive'),
archivedEmailController.getArchivedEmails
);
router.get('/:id', archivedEmailController.getArchivedEmailById);
router.get(
'/:id',
requirePermission('read', 'archive'),
archivedEmailController.getArchivedEmailById
);
return router;
router.delete(
'/:id',
requirePermission('delete', 'archive'),
archivedEmailController.deleteArchivedEmail
);
return router;
};

View File

@@ -3,28 +3,28 @@ import { loginRateLimiter } from '../middleware/rateLimiter';
import type { AuthController } from '../controllers/auth.controller';
export const createAuthRouter = (authController: AuthController): Router => {
const router = Router();
const router = Router();
/**
* @route POST /api/v1/auth/setup
* @description Creates the initial administrator user.
* @access Public
*/
router.post('/setup', loginRateLimiter, authController.setup);
/**
* @route POST /api/v1/auth/setup
* @description Creates the initial administrator user.
* @access Public
*/
router.post('/setup', loginRateLimiter, authController.setup);
/**
* @route POST /api/v1/auth/login
* @description Authenticates a user and returns a JWT.
* @access Public
*/
router.post('/login', loginRateLimiter, authController.login);
/**
* @route POST /api/v1/auth/login
* @description Authenticates a user and returns a JWT.
* @access Public
*/
router.post('/login', loginRateLimiter, authController.login);
/**
* @route GET /api/v1/auth/status
* @description Checks if the application has been set up.
* @access Public
*/
router.get('/status', authController.status);
/**
* @route GET /api/v1/auth/status
* @description Checks if the application has been set up.
* @access Public
*/
router.get('/status', authController.status);
return router;
return router;
};

View File

@@ -1,18 +1,59 @@
import { Router } from 'express';
import { dashboardController } from '../controllers/dashboard.controller';
import { requireAuth } from '../middleware/requireAuth';
import { requirePermission } from '../middleware/requirePermission';
import { AuthService } from '../../services/AuthService';
export const createDashboardRouter = (authService: AuthService): Router => {
const router = Router();
const router = Router();
router.use(requireAuth(authService));
router.use(requireAuth(authService));
router.get('/stats', dashboardController.getStats);
router.get('/ingestion-history', dashboardController.getIngestionHistory);
router.get('/ingestion-sources', dashboardController.getIngestionSources);
router.get('/recent-syncs', dashboardController.getRecentSyncs);
router.get('/indexed-insights', dashboardController.getIndexedInsights);
router.get(
'/stats',
requirePermission(
'read',
'dashboard',
'You need the dashboard read permission to view dashboard stats.'
),
dashboardController.getStats
);
router.get(
'/ingestion-history',
requirePermission(
'read',
'dashboard',
'You need the dashboard read permission to view dashboard data.'
),
dashboardController.getIngestionHistory
);
router.get(
'/ingestion-sources',
requirePermission(
'read',
'dashboard',
'You need the dashboard read permission to view dashboard data.'
),
dashboardController.getIngestionSources
);
router.get(
'/recent-syncs',
requirePermission(
'read',
'dashboard',
'You need the dashboard read permission to view dashboard data.'
),
dashboardController.getRecentSyncs
);
router.get(
'/indexed-insights',
requirePermission(
'read',
'dashboard',
'You need the dashboard read permission to view dashboard data.'
),
dashboardController.getIndexedInsights
);
return router;
return router;
};

View File

@@ -1,36 +1,42 @@
import { Router } from 'express';
import { requireAuth } from '../middleware/requireAuth';
import { requirePermission } from '../middleware/requirePermission';
import type { IamController } from '../controllers/iam.controller';
import type { AuthService } from '../../services/AuthService';
export const createIamRouter = (iamController: IamController): Router => {
const router = Router();
export const createIamRouter = (iamController: IamController, authService: AuthService): Router => {
const router = Router();
/**
* @route GET /api/v1/iam/roles
* @description Gets all roles.
* @access Private
*/
router.get('/roles', requireAuth, iamController.getRoles);
router.use(requireAuth(authService));
/**
* @route GET /api/v1/iam/roles/:id
* @description Gets a role by ID.
* @access Private
*/
router.get('/roles/:id', requireAuth, iamController.getRoleById);
/**
* @route GET /api/v1/iam/roles
* @description Gets all roles.
* @access Private
*/
router.get('/roles', requirePermission('read', 'roles'), iamController.getRoles);
/**
* @route POST /api/v1/iam/roles
* @description Creates a new role.
* @access Private
*/
router.post('/roles', requireAuth, iamController.createRole);
router.get('/roles/:id', requirePermission('read', 'roles'), iamController.getRoleById);
/**
* @route DELETE /api/v1/iam/roles/:id
* @description Deletes a role.
* @access Private
*/
router.delete('/roles/:id', requireAuth, iamController.deleteRole);
return router;
/**
* Only super admin has the ability to modify existing roles or create new roles.
*/
router.post(
'/roles',
requirePermission('manage', 'all', 'Super Admin role is required to manage roles.'),
iamController.createRole
);
router.delete(
'/roles/:id',
requirePermission('manage', 'all', 'Super Admin role is required to manage roles.'),
iamController.deleteRole
);
router.put(
'/roles/:id',
requirePermission('manage', 'all', 'Super Admin role is required to manage roles.'),
iamController.updateRole
);
return router;
};

View File

@@ -1,32 +1,41 @@
import { Router } from 'express';
import { IngestionController } from '../controllers/ingestion.controller';
import { requireAuth } from '../middleware/requireAuth';
import { requirePermission } from '../middleware/requirePermission';
import { AuthService } from '../../services/AuthService';
export const createIngestionRouter = (
ingestionController: IngestionController,
authService: AuthService
ingestionController: IngestionController,
authService: AuthService
): Router => {
const router = Router();
const router = Router();
// Secure all routes in this module
router.use(requireAuth(authService));
// Secure all routes in this module
router.use(requireAuth(authService));
router.post('/', ingestionController.create);
router.post('/', requirePermission('create', 'ingestion'), ingestionController.create);
router.get('/', ingestionController.findAll);
router.get('/', requirePermission('read', 'ingestion'), ingestionController.findAll);
router.get('/:id', ingestionController.findById);
router.get('/:id', requirePermission('read', 'ingestion'), ingestionController.findById);
router.put('/:id', ingestionController.update);
router.put('/:id', requirePermission('update', 'ingestion'), ingestionController.update);
router.delete('/:id', ingestionController.delete);
router.delete('/:id', requirePermission('delete', 'ingestion'), ingestionController.delete);
router.post('/:id/import', ingestionController.triggerInitialImport);
router.post(
'/:id/import',
requirePermission('create', 'ingestion'),
ingestionController.triggerInitialImport
);
router.post('/:id/pause', ingestionController.pause);
router.post('/:id/pause', requirePermission('update', 'ingestion'), ingestionController.pause);
router.post('/:id/sync', ingestionController.triggerForceSync);
router.post(
'/:id/sync',
requirePermission('sync', 'ingestion'),
ingestionController.triggerForceSync
);
return router;
return router;
};

View File

@@ -1,17 +1,18 @@
import { Router } from 'express';
import { SearchController } from '../controllers/search.controller';
import { requireAuth } from '../middleware/requireAuth';
import { requirePermission } from '../middleware/requirePermission';
import { AuthService } from '../../services/AuthService';
export const createSearchRouter = (
searchController: SearchController,
authService: AuthService
searchController: SearchController,
authService: AuthService
): Router => {
const router = Router();
const router = Router();
router.use(requireAuth(authService));
router.use(requireAuth(authService));
router.get('/', searchController.search);
router.get('/', requirePermission('search', 'archive'), searchController.search);
return router;
return router;
};

View File

@@ -1,18 +1,19 @@
import { Router } from 'express';
import { StorageController } from '../controllers/storage.controller';
import { requireAuth } from '../middleware/requireAuth';
import { requirePermission } from '../middleware/requirePermission';
import { AuthService } from '../../services/AuthService';
export const createStorageRouter = (
storageController: StorageController,
authService: AuthService
storageController: StorageController,
authService: AuthService
): Router => {
const router = Router();
const router = Router();
// Secure all routes in this module
router.use(requireAuth(authService));
// Secure all routes in this module
router.use(requireAuth(authService));
router.get('/download', storageController.downloadFile);
router.get('/download', requirePermission('read', 'archive'), storageController.downloadFile);
return router;
return router;
};

View File

@@ -3,7 +3,4 @@ import { ingestionQueue } from '../../jobs/queues';
const router: Router = Router();
export default router;

View File

@@ -2,13 +2,14 @@ import { Router } from 'express';
import { uploadFile } from '../controllers/upload.controller';
import { requireAuth } from '../middleware/requireAuth';
import { AuthService } from '../../services/AuthService';
import { requirePermission } from '../middleware/requirePermission';
export const createUploadRouter = (authService: AuthService): Router => {
const router = Router();
const router = Router();
router.use(requireAuth(authService));
router.use(requireAuth(authService));
router.post('/', uploadFile);
router.post('/', requirePermission('create', 'ingestion'), uploadFile);
return router;
return router;
};

View File

@@ -0,0 +1,38 @@
import { Router } from 'express';
import * as userController from '../controllers/user.controller';
import { requireAuth } from '../middleware/requireAuth';
import { requirePermission } from '../middleware/requirePermission';
import { AuthService } from '../../services/AuthService';
export const createUserRouter = (authService: AuthService): Router => {
const router = Router();
router.use(requireAuth(authService));
router.get('/', requirePermission('read', 'users'), userController.getUsers);
router.get('/:id', requirePermission('read', 'users'), userController.getUser);
/**
* Only super admin has the ability to modify existing users or create new users.
*/
router.post(
'/',
requirePermission('manage', 'all', 'Super Admin role is required to manage users.'),
userController.createUser
);
router.put(
'/:id',
requirePermission('manage', 'all', 'Super Admin role is required to manage users.'),
userController.updateUser
);
router.delete(
'/:id',
requirePermission('manage', 'all', 'Super Admin role is required to manage users.'),
userController.deleteUser
);
return router;
};

View File

@@ -1,9 +1,9 @@
import 'dotenv/config';
export const app = {
nodeEnv: process.env.NODE_ENV || 'development',
port: process.env.PORT_BACKEND ? parseInt(process.env.PORT_BACKEND, 10) : 4000,
encryptionKey: process.env.ENCRYPTION_KEY,
isDemo: process.env.IS_DEMO === 'true',
syncFrequency: process.env.SYNC_FREQUENCY || '* * * * *' //default to 1 minute
nodeEnv: process.env.NODE_ENV || 'development',
port: process.env.PORT_BACKEND ? parseInt(process.env.PORT_BACKEND, 10) : 4000,
encryptionKey: process.env.ENCRYPTION_KEY,
isDemo: process.env.IS_DEMO === 'true',
syncFrequency: process.env.SYNC_FREQUENCY || '* * * * *', //default to 1 minute
};

View File

@@ -4,8 +4,8 @@ import { searchConfig } from './search';
import { connection as redisConfig } from './redis';
export const config = {
storage,
app,
search: searchConfig,
redis: redisConfig,
storage,
app,
search: searchConfig,
redis: redisConfig,
};

View File

@@ -1,11 +1,11 @@
import pino from 'pino';
export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: {
target: 'pino-pretty',
options: {
colorize: true
}
}
level: process.env.LOG_LEVEL || 'info',
transport: {
target: 'pino-pretty',
options: {
colorize: true,
},
},
});

View File

@@ -4,16 +4,16 @@ import 'dotenv/config';
* @see https://github.com/taskforcesh/bullmq/blob/master/docs/gitbook/guide/connections.md
*/
const connectionOptions: any = {
host: process.env.REDIS_HOST || 'localhost',
port: (process.env.REDIS_PORT && parseInt(process.env.REDIS_PORT, 10)) || 6379,
password: process.env.REDIS_PASSWORD,
enableReadyCheck: true,
host: process.env.REDIS_HOST || 'localhost',
port: (process.env.REDIS_PORT && parseInt(process.env.REDIS_PORT, 10)) || 6379,
password: process.env.REDIS_PASSWORD,
enableReadyCheck: true,
};
if (process.env.REDIS_TLS_ENABLED === 'true') {
connectionOptions.tls = {
rejectUnauthorized: false
};
connectionOptions.tls = {
rejectUnauthorized: false,
};
}
export const connection = connectionOptions;

View File

@@ -1,6 +1,6 @@
import 'dotenv/config';
export const searchConfig = {
host: process.env.MEILI_HOST || 'http://127.0.0.1:7700',
apiKey: process.env.MEILI_MASTER_KEY || '',
host: process.env.MEILI_HOST || 'http://127.0.0.1:7700',
apiKey: process.env.MEILI_MASTER_KEY || '',
};

View File

@@ -6,35 +6,35 @@ const openArchiverFolderName = 'open-archiver';
let storageConfig: StorageConfig;
if (storageType === 'local') {
if (!process.env.STORAGE_LOCAL_ROOT_PATH) {
throw new Error('STORAGE_LOCAL_ROOT_PATH is not defined in the environment variables');
}
storageConfig = {
type: 'local',
rootPath: process.env.STORAGE_LOCAL_ROOT_PATH,
openArchiverFolderName: openArchiverFolderName
};
if (!process.env.STORAGE_LOCAL_ROOT_PATH) {
throw new Error('STORAGE_LOCAL_ROOT_PATH is not defined in the environment variables');
}
storageConfig = {
type: 'local',
rootPath: process.env.STORAGE_LOCAL_ROOT_PATH,
openArchiverFolderName: openArchiverFolderName,
};
} else if (storageType === 's3') {
if (
!process.env.STORAGE_S3_ENDPOINT ||
!process.env.STORAGE_S3_BUCKET ||
!process.env.STORAGE_S3_ACCESS_KEY_ID ||
!process.env.STORAGE_S3_SECRET_ACCESS_KEY
) {
throw new Error('One or more S3 storage environment variables are not defined');
}
storageConfig = {
type: 's3',
endpoint: process.env.STORAGE_S3_ENDPOINT,
bucket: process.env.STORAGE_S3_BUCKET,
accessKeyId: process.env.STORAGE_S3_ACCESS_KEY_ID,
secretAccessKey: process.env.STORAGE_S3_SECRET_ACCESS_KEY,
region: process.env.STORAGE_S3_REGION,
forcePathStyle: process.env.STORAGE_S3_FORCE_PATH_STYLE === 'true',
openArchiverFolderName: openArchiverFolderName
};
if (
!process.env.STORAGE_S3_ENDPOINT ||
!process.env.STORAGE_S3_BUCKET ||
!process.env.STORAGE_S3_ACCESS_KEY_ID ||
!process.env.STORAGE_S3_SECRET_ACCESS_KEY
) {
throw new Error('One or more S3 storage environment variables are not defined');
}
storageConfig = {
type: 's3',
endpoint: process.env.STORAGE_S3_ENDPOINT,
bucket: process.env.STORAGE_S3_BUCKET,
accessKeyId: process.env.STORAGE_S3_ACCESS_KEY_ID,
secretAccessKey: process.env.STORAGE_S3_SECRET_ACCESS_KEY,
region: process.env.STORAGE_S3_REGION,
forcePathStyle: process.env.STORAGE_S3_FORCE_PATH_STYLE === 'true',
openArchiverFolderName: openArchiverFolderName,
};
} else {
throw new Error(`Invalid STORAGE_TYPE: ${storageType}`);
throw new Error(`Invalid STORAGE_TYPE: ${storageType}`);
}
export const storage = storageConfig;

View File

@@ -3,10 +3,12 @@ import postgres from 'postgres';
import 'dotenv/config';
import * as schema from './schema';
import { encodeDatabaseUrl } from '../helpers/db';
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');
}
const client = postgres(process.env.DATABASE_URL);
const connectionString = encodeDatabaseUrl(process.env.DATABASE_URL);
const client = postgres(connectionString);
export const db = drizzle(client, { schema });

View File

@@ -2,26 +2,28 @@ import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { config } from 'dotenv';
import { encodeDatabaseUrl } from '../helpers/db';
config();
const runMigrate = async () => {
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL is not set in the .env file');
}
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL is not set in the .env file');
}
const connection = postgres(process.env.DATABASE_URL, { max: 1 });
const db = drizzle(connection);
const connectionString = encodeDatabaseUrl(process.env.DATABASE_URL);
const connection = postgres(connectionString, { max: 1 });
const db = drizzle(connection);
console.log('Running migrations...');
console.log('Running migrations...');
await migrate(db, { migrationsFolder: 'src/database/migrations' });
await migrate(db, { migrationsFolder: 'src/database/migrations' });
console.log('Migrations completed!');
process.exit(0);
console.log('Migrations completed!');
process.exit(0);
};
runMigrate().catch((err) => {
console.error('Migration failed!', err);
process.exit(1);
console.error('Migration failed!', err);
process.exit(1);
});

View File

@@ -0,0 +1,2 @@
ALTER TABLE "ingestion_sources" ADD COLUMN "user_id" uuid;--> statement-breakpoint
ALTER TABLE "ingestion_sources" ADD CONSTRAINT "ingestion_sources_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "roles" ADD COLUMN "slug" text;--> statement-breakpoint
ALTER TABLE "roles" ADD CONSTRAINT "roles_slug_unique" UNIQUE("slug");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,111 +1,125 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1752225352591,
"tag": "0000_amusing_namora",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1752326803882,
"tag": "0001_odd_night_thrasher",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1752332648392,
"tag": "0002_lethal_quentin_quire",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1752332967084,
"tag": "0003_petite_wrecker",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1752606108876,
"tag": "0004_sleepy_paper_doll",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1752606327253,
"tag": "0005_chunky_sue_storm",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1753112018514,
"tag": "0006_majestic_caretaker",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1753190159356,
"tag": "0007_handy_archangel",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1753370737317,
"tag": "0008_eminent_the_spike",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1754337938241,
"tag": "0009_late_lenny_balinger",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1754420780849,
"tag": "0010_perpetual_lightspeed",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1754422064158,
"tag": "0011_tan_blackheart",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1754476962901,
"tag": "0012_warm_the_stranger",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1754659373517,
"tag": "0013_classy_talkback",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1754831765718,
"tag": "0014_foamy_vapor",
"breakpoints": true
}
]
}
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1752225352591,
"tag": "0000_amusing_namora",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1752326803882,
"tag": "0001_odd_night_thrasher",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1752332648392,
"tag": "0002_lethal_quentin_quire",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1752332967084,
"tag": "0003_petite_wrecker",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1752606108876,
"tag": "0004_sleepy_paper_doll",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1752606327253,
"tag": "0005_chunky_sue_storm",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1753112018514,
"tag": "0006_majestic_caretaker",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1753190159356,
"tag": "0007_handy_archangel",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1753370737317,
"tag": "0008_eminent_the_spike",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1754337938241,
"tag": "0009_late_lenny_balinger",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1754420780849,
"tag": "0010_perpetual_lightspeed",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1754422064158,
"tag": "0011_tan_blackheart",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1754476962901,
"tag": "0012_warm_the_stranger",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1754659373517,
"tag": "0013_classy_talkback",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1754831765718,
"tag": "0014_foamy_vapor",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1755443936046,
"tag": "0015_wakeful_norman_osborn",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1755780572342,
"tag": "0016_lonely_mariko_yashida",
"breakpoints": true
}
]
}

View File

@@ -3,36 +3,36 @@ import { boolean, jsonb, pgTable, text, timestamp, uuid, bigint, index } from 'd
import { ingestionSources } from './ingestion-sources';
export const archivedEmails = pgTable(
'archived_emails',
{
id: uuid('id').primaryKey().defaultRandom(),
threadId: text('thread_id'),
ingestionSourceId: uuid('ingestion_source_id')
.notNull()
.references(() => ingestionSources.id, { onDelete: 'cascade' }),
userEmail: text('user_email').notNull(),
messageIdHeader: text('message_id_header'),
sentAt: timestamp('sent_at', { withTimezone: true }).notNull(),
subject: text('subject'),
senderName: text('sender_name'),
senderEmail: text('sender_email').notNull(),
recipients: jsonb('recipients'),
storagePath: text('storage_path').notNull(),
storageHashSha256: text('storage_hash_sha256').notNull(),
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
isIndexed: boolean('is_indexed').notNull().default(false),
hasAttachments: boolean('has_attachments').notNull().default(false),
isOnLegalHold: boolean('is_on_legal_hold').notNull().default(false),
archivedAt: timestamp('archived_at', { withTimezone: true }).notNull().defaultNow(),
path: text('path'),
tags: jsonb('tags'),
},
(table) => [index('thread_id_idx').on(table.threadId)]
'archived_emails',
{
id: uuid('id').primaryKey().defaultRandom(),
threadId: text('thread_id'),
ingestionSourceId: uuid('ingestion_source_id')
.notNull()
.references(() => ingestionSources.id, { onDelete: 'cascade' }),
userEmail: text('user_email').notNull(),
messageIdHeader: text('message_id_header'),
sentAt: timestamp('sent_at', { withTimezone: true }).notNull(),
subject: text('subject'),
senderName: text('sender_name'),
senderEmail: text('sender_email').notNull(),
recipients: jsonb('recipients'),
storagePath: text('storage_path').notNull(),
storageHashSha256: text('storage_hash_sha256').notNull(),
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
isIndexed: boolean('is_indexed').notNull().default(false),
hasAttachments: boolean('has_attachments').notNull().default(false),
isOnLegalHold: boolean('is_on_legal_hold').notNull().default(false),
archivedAt: timestamp('archived_at', { withTimezone: true }).notNull().defaultNow(),
path: text('path'),
tags: jsonb('tags'),
},
(table) => [index('thread_id_idx').on(table.threadId)]
);
export const archivedEmailsRelations = relations(archivedEmails, ({ one }) => ({
ingestionSource: one(ingestionSources, {
fields: [archivedEmails.ingestionSourceId],
references: [ingestionSources.id]
})
ingestionSource: one(ingestionSources, {
fields: [archivedEmails.ingestionSourceId],
references: [ingestionSources.id],
}),
}));

View File

@@ -3,32 +3,40 @@ import { pgTable, text, uuid, bigint, primaryKey } from 'drizzle-orm/pg-core';
import { archivedEmails } from './archived-emails';
export const attachments = pgTable('attachments', {
id: uuid('id').primaryKey().defaultRandom(),
filename: text('filename').notNull(),
mimeType: text('mime_type'),
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
contentHashSha256: text('content_hash_sha256').notNull().unique(),
storagePath: text('storage_path').notNull(),
id: uuid('id').primaryKey().defaultRandom(),
filename: text('filename').notNull(),
mimeType: text('mime_type'),
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
contentHashSha256: text('content_hash_sha256').notNull().unique(),
storagePath: text('storage_path').notNull(),
});
export const emailAttachments = pgTable('email_attachments', {
emailId: uuid('email_id').notNull().references(() => archivedEmails.id, { onDelete: 'cascade' }),
attachmentId: uuid('attachment_id').notNull().references(() => attachments.id, { onDelete: 'restrict' }),
}, (t) => ({
pk: primaryKey({ columns: [t.emailId, t.attachmentId] }),
}));
export const emailAttachments = pgTable(
'email_attachments',
{
emailId: uuid('email_id')
.notNull()
.references(() => archivedEmails.id, { onDelete: 'cascade' }),
attachmentId: uuid('attachment_id')
.notNull()
.references(() => attachments.id, { onDelete: 'restrict' }),
},
(t) => ({
pk: primaryKey({ columns: [t.emailId, t.attachmentId] }),
})
);
export const attachmentsRelations = relations(attachments, ({ many }) => ({
emailAttachments: many(emailAttachments),
emailAttachments: many(emailAttachments),
}));
export const emailAttachmentsRelations = relations(emailAttachments, ({ one }) => ({
archivedEmail: one(archivedEmails, {
fields: [emailAttachments.emailId],
references: [archivedEmails.id],
}),
attachment: one(attachments, {
fields: [emailAttachments.attachmentId],
references: [attachments.id],
}),
archivedEmail: one(archivedEmails, {
fields: [emailAttachments.emailId],
references: [archivedEmails.id],
}),
attachment: one(attachments, {
fields: [emailAttachments.attachmentId],
references: [attachments.id],
}),
}));

View File

@@ -1,12 +1,12 @@
import { bigserial, boolean, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
export const auditLogs = pgTable('audit_logs', {
id: bigserial('id', { mode: 'number' }).primaryKey(),
timestamp: timestamp('timestamp', { withTimezone: true }).notNull().defaultNow(),
actorIdentifier: text('actor_identifier').notNull(),
action: text('action').notNull(),
targetType: text('target_type'),
targetId: text('target_id'),
details: jsonb('details'),
isTamperEvident: boolean('is_tamper_evident').default(false),
id: bigserial('id', { mode: 'number' }).primaryKey(),
timestamp: timestamp('timestamp', { withTimezone: true }).notNull().defaultNow(),
actorIdentifier: text('actor_identifier').notNull(),
action: text('action').notNull(),
targetType: text('target_type'),
targetId: text('target_id'),
details: jsonb('details'),
isTamperEvident: boolean('is_tamper_evident').default(false),
});

View File

@@ -1,80 +1,94 @@
import { relations } from 'drizzle-orm';
import { boolean, integer, jsonb, pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import {
boolean,
integer,
jsonb,
pgEnum,
pgTable,
text,
timestamp,
uuid,
} from 'drizzle-orm/pg-core';
import { custodians } from './custodians';
// --- Enums ---
export const retentionActionEnum = pgEnum('retention_action', ['delete_permanently', 'notify_admin']);
export const retentionActionEnum = pgEnum('retention_action', [
'delete_permanently',
'notify_admin',
]);
// --- Tables ---
export const retentionPolicies = pgTable('retention_policies', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
description: text('description'),
priority: integer('priority').notNull(),
retentionPeriodDays: integer('retention_period_days').notNull(),
actionOnExpiry: retentionActionEnum('action_on_expiry').notNull(),
isEnabled: boolean('is_enabled').notNull().default(true),
conditions: jsonb('conditions'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
description: text('description'),
priority: integer('priority').notNull(),
retentionPeriodDays: integer('retention_period_days').notNull(),
actionOnExpiry: retentionActionEnum('action_on_expiry').notNull(),
isEnabled: boolean('is_enabled').notNull().default(true),
conditions: jsonb('conditions'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const ediscoveryCases = pgTable('ediscovery_cases', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
description: text('description'),
status: text('status').notNull().default('open'),
createdByIdentifier: text('created_by_identifier').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
description: text('description'),
status: text('status').notNull().default('open'),
createdByIdentifier: text('created_by_identifier').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const legalHolds = pgTable('legal_holds', {
id: uuid('id').primaryKey().defaultRandom(),
caseId: uuid('case_id').notNull().references(() => ediscoveryCases.id, { onDelete: 'cascade' }),
custodianId: uuid('custodian_id').references(() => custodians.id, { onDelete: 'cascade' }),
holdCriteria: jsonb('hold_criteria'),
reason: text('reason'),
appliedByIdentifier: text('applied_by_identifier').notNull(),
appliedAt: timestamp('applied_at', { withTimezone: true }).notNull().defaultNow(),
removedAt: timestamp('removed_at', { withTimezone: true }),
id: uuid('id').primaryKey().defaultRandom(),
caseId: uuid('case_id')
.notNull()
.references(() => ediscoveryCases.id, { onDelete: 'cascade' }),
custodianId: uuid('custodian_id').references(() => custodians.id, { onDelete: 'cascade' }),
holdCriteria: jsonb('hold_criteria'),
reason: text('reason'),
appliedByIdentifier: text('applied_by_identifier').notNull(),
appliedAt: timestamp('applied_at', { withTimezone: true }).notNull().defaultNow(),
removedAt: timestamp('removed_at', { withTimezone: true }),
});
export const exportJobs = pgTable('export_jobs', {
id: uuid('id').primaryKey().defaultRandom(),
caseId: uuid('case_id').references(() => ediscoveryCases.id, { onDelete: 'set null' }),
format: text('format').notNull(),
status: text('status').notNull().default('pending'),
query: jsonb('query').notNull(),
filePath: text('file_path'),
createdByIdentifier: text('created_by_identifier').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
completedAt: timestamp('completed_at', { withTimezone: true }),
id: uuid('id').primaryKey().defaultRandom(),
caseId: uuid('case_id').references(() => ediscoveryCases.id, { onDelete: 'set null' }),
format: text('format').notNull(),
status: text('status').notNull().default('pending'),
query: jsonb('query').notNull(),
filePath: text('file_path'),
createdByIdentifier: text('created_by_identifier').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
completedAt: timestamp('completed_at', { withTimezone: true }),
});
// --- Relations ---
export const ediscoveryCasesRelations = relations(ediscoveryCases, ({ many }) => ({
legalHolds: many(legalHolds),
exportJobs: many(exportJobs),
legalHolds: many(legalHolds),
exportJobs: many(exportJobs),
}));
export const legalHoldsRelations = relations(legalHolds, ({ one }) => ({
ediscoveryCase: one(ediscoveryCases, {
fields: [legalHolds.caseId],
references: [ediscoveryCases.id],
}),
custodian: one(custodians, {
fields: [legalHolds.custodianId],
references: [custodians.id],
}),
ediscoveryCase: one(ediscoveryCases, {
fields: [legalHolds.caseId],
references: [ediscoveryCases.id],
}),
custodian: one(custodians, {
fields: [legalHolds.custodianId],
references: [custodians.id],
}),
}));
export const exportJobsRelations = relations(exportJobs, ({ one }) => ({
ediscoveryCase: one(ediscoveryCases, {
fields: [exportJobs.caseId],
references: [ediscoveryCases.id],
}),
ediscoveryCase: one(ediscoveryCases, {
fields: [exportJobs.caseId],
references: [ediscoveryCases.id],
}),
}));

View File

@@ -2,10 +2,10 @@ import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { ingestionProviderEnum } from './ingestion-sources';
export const custodians = pgTable('custodians', {
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').notNull().unique(),
displayName: text('display_name'),
sourceType: ingestionProviderEnum('source_type').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').notNull().unique(),
displayName: text('display_name'),
sourceType: ingestionProviderEnum('source_type').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});

View File

@@ -1,34 +1,44 @@
import { jsonb, pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { users } from './users';
import { relations } from 'drizzle-orm';
export const ingestionProviderEnum = pgEnum('ingestion_provider', [
'google_workspace',
'microsoft_365',
'generic_imap',
'pst_import',
'eml_import'
'google_workspace',
'microsoft_365',
'generic_imap',
'pst_import',
'eml_import',
]);
export const ingestionStatusEnum = pgEnum('ingestion_status', [
'active',
'paused',
'error',
'pending_auth',
'syncing',
'importing',
'auth_success',
'imported'
'active',
'paused',
'error',
'pending_auth',
'syncing',
'importing',
'auth_success',
'imported',
]);
export const ingestionSources = pgTable('ingestion_sources', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
provider: ingestionProviderEnum('provider').notNull(),
credentials: text('credentials'),
status: ingestionStatusEnum('status').notNull().default('pending_auth'),
lastSyncStartedAt: timestamp('last_sync_started_at', { withTimezone: true }),
lastSyncFinishedAt: timestamp('last_sync_finished_at', { withTimezone: true }),
lastSyncStatusMessage: text('last_sync_status_message'),
syncState: jsonb('sync_state'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
provider: ingestionProviderEnum('provider').notNull(),
credentials: text('credentials'),
status: ingestionStatusEnum('status').notNull().default('pending_auth'),
lastSyncStartedAt: timestamp('last_sync_started_at', { withTimezone: true }),
lastSyncFinishedAt: timestamp('last_sync_finished_at', { withTimezone: true }),
lastSyncStatusMessage: text('last_sync_status_message'),
syncState: jsonb('sync_state'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const ingestionSourcesRelations = relations(ingestionSources, ({ one }) => ({
user: one(users, {
fields: [ingestionSources.userId],
references: [users.id],
}),
}));

View File

@@ -1,27 +1,20 @@
import { relations, sql } from 'drizzle-orm';
import {
pgTable,
text,
timestamp,
uuid,
primaryKey,
jsonb
} from 'drizzle-orm/pg-core';
import type { PolicyStatement } from '@open-archiver/types';
import { pgTable, text, timestamp, uuid, primaryKey, jsonb } from 'drizzle-orm/pg-core';
import type { CaslPolicy } from '@open-archiver/types';
/**
* The `users` table stores the core user information for authentication and identification.
*/
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').notNull().unique(),
first_name: text('first_name'),
last_name: text('last_name'),
password: text('password'),
provider: text('provider').default('local'),
providerId: text('provider_id'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').notNull().unique(),
first_name: text('first_name'),
last_name: text('last_name'),
password: text('password'),
provider: text('provider').default('local'),
providerId: text('provider_id'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
/**
@@ -29,14 +22,14 @@ export const users = pgTable('users', {
* It links a session to a user and records its expiration time.
*/
export const sessions = pgTable('sessions', {
id: text('id').primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at', {
withTimezone: true,
mode: 'date'
}).notNull()
id: text('id').primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at', {
withTimezone: true,
mode: 'date',
}).notNull(),
});
/**
@@ -44,11 +37,15 @@ export const sessions = pgTable('sessions', {
* Each role has a name and a set of policies that define its permissions.
*/
export const roles = pgTable('roles', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
policies: jsonb('policies').$type<PolicyStatement[]>().notNull().default(sql`'[]'::jsonb`),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
policies: jsonb('policies')
.$type<CaslPolicy[]>()
.notNull()
.default(sql`'[]'::jsonb`),
slug: text('slug').unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
/**
@@ -56,34 +53,34 @@ export const roles = pgTable('roles', {
* This many-to-many relationship allows a user to have multiple roles.
*/
export const userRoles = pgTable(
'user_roles',
{
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
roleId: uuid('role_id')
.notNull()
.references(() => roles.id, { onDelete: 'cascade' })
},
(t) => [primaryKey({ columns: [t.userId, t.roleId] })]
'user_roles',
{
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
roleId: uuid('role_id')
.notNull()
.references(() => roles.id, { onDelete: 'cascade' }),
},
(t) => [primaryKey({ columns: [t.userId, t.roleId] })]
);
// Define relationships for Drizzle ORM
export const usersRelations = relations(users, ({ many }) => ({
userRoles: many(userRoles)
userRoles: many(userRoles),
}));
export const rolesRelations = relations(roles, ({ many }) => ({
userRoles: many(userRoles)
userRoles: many(userRoles),
}));
export const userRolesRelations = relations(userRoles, ({ one }) => ({
role: one(roles, {
fields: [userRoles.roleId],
references: [roles.id]
}),
user: one(users, {
fields: [userRoles.userId],
references: [users.id]
})
role: one(roles, {
fields: [userRoles.roleId],
references: [roles.id],
}),
user: one(users, {
fields: [userRoles.userId],
references: [users.id],
}),
}));

View File

@@ -0,0 +1,12 @@
export const encodeDatabaseUrl = (databaseUrl: string): string => {
try {
const url = new URL(databaseUrl);
if (url.password) {
url.password = encodeURIComponent(url.password);
}
return url.toString();
} catch (error) {
console.error('Invalid DATABASE_URL, please check your .env file.', error);
throw new Error('Invalid DATABASE_URL');
}
};

View File

@@ -0,0 +1,95 @@
import { SQL, and, or, not, eq, gt, gte, lt, lte, inArray, isNull, sql } from 'drizzle-orm';
const camelToSnakeCase = (str: string) =>
str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
const relationToTableMap: Record<string, string> = {
ingestionSource: 'ingestion_sources',
// TBD: Add other relations here as needed
};
function getDrizzleColumn(key: string): SQL {
const keyParts = key.split('.');
if (keyParts.length > 1) {
const relationName = keyParts[0];
const columnName = camelToSnakeCase(keyParts[1]);
const tableName = relationToTableMap[relationName];
if (tableName) {
return sql.raw(`"${tableName}"."${columnName}"`);
}
}
return sql`${sql.identifier(camelToSnakeCase(key))}`;
}
export function mongoToDrizzle(query: Record<string, any>): SQL | undefined {
const conditions: (SQL | undefined)[] = [];
for (const key in query) {
const value = query[key];
if (key === '$or') {
conditions.push(or(...(value as any[]).map(mongoToDrizzle).filter(Boolean)));
continue;
}
if (key === '$and') {
conditions.push(and(...(value as any[]).map(mongoToDrizzle).filter(Boolean)));
continue;
}
if (key === '$not') {
const subQuery = mongoToDrizzle(value);
if (subQuery) {
conditions.push(not(subQuery));
}
continue;
}
const column = getDrizzleColumn(key);
if (typeof value === 'object' && value !== null) {
const operator = Object.keys(value)[0];
const operand = value[operator];
switch (operator) {
case '$eq':
conditions.push(eq(column, operand));
break;
case '$ne':
conditions.push(not(eq(column, operand)));
break;
case '$gt':
conditions.push(gt(column, operand));
break;
case '$gte':
conditions.push(gte(column, operand));
break;
case '$lt':
conditions.push(lt(column, operand));
break;
case '$lte':
conditions.push(lte(column, operand));
break;
case '$in':
conditions.push(inArray(column, operand));
break;
case '$nin':
conditions.push(not(inArray(column, operand)));
break;
case '$exists':
conditions.push(operand ? not(isNull(column)) : isNull(column));
break;
default:
// Unsupported operator
}
} else {
conditions.push(eq(column, value));
}
}
if (conditions.length === 0) {
return undefined;
}
return and(...conditions.filter((c): c is SQL => c !== undefined));
}

View File

@@ -0,0 +1,100 @@
import { db } from '../database';
import { ingestionSources } from '../database/schema';
import { eq } from 'drizzle-orm';
const snakeToCamelCase = (str: string): string => {
return str.replace(/_([a-z])/g, (match, letter) => letter.toUpperCase());
};
function getMeliColumn(key: string): string {
const keyParts = key.split('.');
if (keyParts.length > 1) {
const relationName = keyParts[0];
const columnName = keyParts[1];
return `${relationName}.${columnName}`;
}
return snakeToCamelCase(key);
}
function quoteIfString(value: any): any {
if (typeof value === 'string') {
return `"${value}"`;
}
return value;
}
export async function mongoToMeli(query: Record<string, any>): Promise<string> {
const conditions: string[] = [];
for (const key of Object.keys(query)) {
const value = query[key];
if (key === '$or') {
const orConditions = await Promise.all(value.map(mongoToMeli));
conditions.push(`(${orConditions.join(' OR ')})`);
continue;
}
if (key === '$and') {
const andConditions = await Promise.all(value.map(mongoToMeli));
conditions.push(`(${andConditions.join(' AND ')})`);
continue;
}
if (key === '$not') {
conditions.push(`NOT (${await mongoToMeli(value)})`);
continue;
}
const column = getMeliColumn(key);
if (typeof value === 'object' && value !== null) {
const operator = Object.keys(value)[0];
const operand = value[operator];
switch (operator) {
case '$eq':
conditions.push(`${column} = ${quoteIfString(operand)}`);
break;
case '$ne':
conditions.push(`${column} != ${quoteIfString(operand)}`);
break;
case '$gt':
conditions.push(`${column} > ${operand}`);
break;
case '$gte':
conditions.push(`${column} >= ${operand}`);
break;
case '$lt':
conditions.push(`${column} < ${operand}`);
break;
case '$lte':
conditions.push(`${column} <= ${operand}`);
break;
case '$in':
conditions.push(`${column} IN [${operand.map(quoteIfString).join(', ')}]`);
break;
case '$nin':
conditions.push(`${column} NOT IN [${operand.map(quoteIfString).join(', ')}]`);
break;
case '$exists':
conditions.push(`${column} ${operand ? 'EXISTS' : 'NOT EXISTS'}`);
break;
default:
// Unsupported operator
}
} else {
if (column === 'ingestionSource.userId') {
// for the userId placeholder. (Await for a more elegant solution)
const ingestionsIds = await db
.select({ id: ingestionSources.id })
.from(ingestionSources)
.where(eq(ingestionSources.userId, value));
conditions.push(
`ingestionSourceId IN [${ingestionsIds.map((i) => quoteIfString(i.id)).join(', ')}]`
);
} else {
conditions.push(`${column} = ${quoteIfString(value)}`);
}
}
}
return conditions.join(' AND ');
}

View File

@@ -1,8 +1,8 @@
export function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on('data', (chunk) => chunks.push(chunk));
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks)));
});
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on('data', (chunk) => chunks.push(chunk));
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks)));
});
}

View File

@@ -3,66 +3,68 @@ import mammoth from 'mammoth';
import xlsx from 'xlsx';
function extractTextFromPdf(buffer: Buffer): Promise<string> {
return new Promise((resolve, reject) => {
const pdfParser = new PDFParser(null, true);
return new Promise((resolve) => {
const pdfParser = new PDFParser(null, true);
let completed = false;
pdfParser.on('pdfParser_dataError', (errData: any) =>
reject(new Error(errData.parserError))
);
pdfParser.on('pdfParser_dataReady', () => {
resolve(pdfParser.getRawTextContent());
});
const finish = (text: string) => {
if (completed) return;
completed = true;
pdfParser.removeAllListeners();
resolve(text);
};
pdfParser.parseBuffer(buffer);
});
pdfParser.on('pdfParser_dataError', () => finish(''));
pdfParser.on('pdfParser_dataReady', () => finish(pdfParser.getRawTextContent()));
try {
pdfParser.parseBuffer(buffer);
} catch (err) {
console.error('Error parsing PDF buffer', err);
finish('');
}
// Prevent hanging if the parser never emits events
setTimeout(() => finish(''), 10000);
});
}
export async function extractText(
buffer: Buffer,
mimeType: string
): Promise<string> {
try {
if (mimeType === 'application/pdf') {
return await extractTextFromPdf(buffer);
}
export async function extractText(buffer: Buffer, mimeType: string): Promise<string> {
try {
if (mimeType === 'application/pdf') {
return await extractTextFromPdf(buffer);
}
if (
mimeType ===
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
) {
const { value } = await mammoth.extractRawText({ buffer });
return value;
}
if (
mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
) {
const { value } = await mammoth.extractRawText({ buffer });
return value;
}
if (
mimeType ===
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
) {
const workbook = xlsx.read(buffer, { type: 'buffer' });
let fullText = '';
for (const sheetName of workbook.SheetNames) {
const sheet = workbook.Sheets[sheetName];
const sheetText = xlsx.utils.sheet_to_txt(sheet);
fullText += sheetText + '\n';
}
return fullText;
}
if (mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') {
const workbook = xlsx.read(buffer, { type: 'buffer' });
let fullText = '';
for (const sheetName of workbook.SheetNames) {
const sheet = workbook.Sheets[sheetName];
const sheetText = xlsx.utils.sheet_to_txt(sheet);
fullText += sheetText + '\n';
}
return fullText;
}
if (
mimeType.startsWith('text/') ||
mimeType === 'application/json' ||
mimeType === 'application/xml'
) {
return buffer.toString('utf-8');
}
} catch (error) {
console.error(
`Error extracting text from attachment with MIME type ${mimeType}:`,
error
);
return ''; // Return empty string on failure
}
if (
mimeType.startsWith('text/') ||
mimeType === 'application/json' ||
mimeType === 'application/xml'
) {
return buffer.toString('utf-8');
}
} catch (error) {
console.error(`Error extracting text from attachment with MIME type ${mimeType}:`, error);
return ''; // Return empty string on failure
}
console.warn(`Unsupported MIME type for text extraction: ${mimeType}`);
return ''; // Return empty string for unsupported types
console.warn(`Unsupported MIME type for text extraction: ${mimeType}`);
return ''; // Return empty string for unsupported types
}

View File

@@ -0,0 +1,118 @@
// packages/backend/src/iam-policy/ability.ts
import { createMongoAbility, MongoAbility, RawRuleOf } from '@casl/ability';
import { CaslPolicy, AppActions, AppSubjects } from '@open-archiver/types';
import { ingestionSources, archivedEmails, users, roles } from '../database/schema';
import { InferSelectModel } from 'drizzle-orm';
// Define the application's ability type
export type AppAbility = MongoAbility<[AppActions, AppSubjects]>;
// Helper type for raw rules
export type AppRawRule = RawRuleOf<AppAbility>;
// Represents the possible object types that can be passed as subjects for permission checks.
export type SubjectObject =
| InferSelectModel<typeof ingestionSources>
| InferSelectModel<typeof archivedEmails>
| InferSelectModel<typeof users>
| InferSelectModel<typeof roles>
| AppSubjects;
// Function to create an ability instance from policies stored in the database
export function createAbilityFor(policies: CaslPolicy[]) {
// We will not expand policies, if a role needs access to ingestion X and its archived emails, the policy should also grant access to archives belonging to ingestion X
// const allPolicies = expandPolicies(policies);
return createMongoAbility<AppAbility>(policies as AppRawRule[]);
}
/**
* @deprecated This function should not be used since we don't need the inheritable behavior anymore.
* Translates conditions on an 'ingestion' subject to equivalent conditions on an 'archive' subject.
* This is used to implement inherent permissions, where permission on an ingestion source
* implies permission on the emails it has ingested.
* @param conditions The original conditions object for the 'ingestion' subject.
* @returns A new conditions object for the 'archive' subject.
*/
function translateIngestionConditionsToArchive(
conditions: Record<string, any>
): Record<string, any> {
if (!conditions || typeof conditions !== 'object') {
return conditions;
}
const translated: Record<string, any> = {};
for (const key in conditions) {
const value = conditions[key];
// Handle logical operators recursively
if (['$or', '$and', '$nor'].includes(key) && Array.isArray(value)) {
translated[key] = value.map((v) => translateIngestionConditionsToArchive(v));
continue;
}
if (key === '$not' && typeof value === 'object' && value !== null) {
translated[key] = translateIngestionConditionsToArchive(value);
continue;
}
// Translate field names
let newKey = key;
if (key === 'id') {
newKey = 'ingestionSourceId';
} else if (['userId', 'name', 'provider', 'status'].includes(key)) {
newKey = `ingestionSource.${key}`;
}
translated[newKey] = value;
}
return translated;
}
/**
* @deprecated This function should not be used since we don't need the inheritable behavior anymore.
* Expands the given set of policies to include inherent permissions.
* For example, a permission on an 'ingestion' source is expanded to grant
* the same permission on 'archive' records related to that source.
* @param policies The original array of CASL policies.
* @returns A new array of policies including the expanded, inherent permissions.
*/
function expandPolicies(policies: CaslPolicy[]): CaslPolicy[] {
const expandedPolicies: CaslPolicy[] = JSON.parse(JSON.stringify(policies));
// Create a set of all actions that are already explicitly defined for the 'archive' subject.
const existingArchiveActions = new Set<string>();
policies.forEach((p) => {
if (p.subject === 'archive') {
const actions = Array.isArray(p.action) ? p.action : [p.action];
actions.forEach((a) => existingArchiveActions.add(a));
}
// Only expand `can` rules for the 'ingestion' subject.
if (p.subject === 'ingestion' && !p.inverted) {
const policyActions = Array.isArray(p.action) ? p.action : [p.action];
// Check if any action in the current ingestion policy already has an explicit archive policy.
const hasExplicitArchiveRule = policyActions.some(
(a) => existingArchiveActions.has(a) || existingArchiveActions.has('manage')
);
// If a more specific rule for 'archive' already exists, do not expand this ingestion rule,
// as it would create a conflicting, overly permissive rule.
if (hasExplicitArchiveRule) {
return;
}
const archivePolicy: CaslPolicy = {
...JSON.parse(JSON.stringify(p)),
subject: 'archive',
};
if (p.conditions) {
archivePolicy.conditions = translateIngestionConditionsToArchive(p.conditions);
}
expandedPolicies.push(archivePolicy);
}
});
policies.forEach((policy) => {});
return expandedPolicies;
}

View File

@@ -1,120 +0,0 @@
/**
* @file This file serves as the single source of truth for all Identity and Access Management (IAM)
* definitions within Open Archiver. Centralizing these definitions is an industry-standard practice
* that offers several key benefits:
*
* 1. **Prevents "Magic Strings"**: Avoids the use of hardcoded strings for actions and resources
* throughout the codebase, reducing the risk of typos and inconsistencies.
* 2. **Single Source of Truth**: Provides a clear, comprehensive, and maintainable list of all
* possible permissions in the system.
* 3. **Enables Validation**: Allows for the creation of a robust validation function that can
* programmatically check if a policy statement is valid before it is saved.
* 4. **Simplifies Auditing**: Makes it easy to audit and understand the scope of permissions
* that can be granted.
*
* The structure is inspired by AWS IAM, using a `service:operation` format for actions and a
* hierarchical, slash-separated path for resources.
*/
// ===================================================================================
// SERVICE: archive
// ===================================================================================
const ARCHIVE_ACTIONS = {
READ: 'archive:read',
SEARCH: 'archive:search',
EXPORT: 'archive:export',
} as const;
const ARCHIVE_RESOURCES = {
ALL: 'archive/all',
INGESTION_SOURCE: 'archive/ingestion-source/*',
MAILBOX: 'archive/mailbox/*',
CUSTODIAN: 'archive/custodian/*',
} as const;
// ===================================================================================
// SERVICE: ingestion
// ===================================================================================
const INGESTION_ACTIONS = {
CREATE_SOURCE: 'ingestion:createSource',
READ_SOURCE: 'ingestion:readSource',
UPDATE_SOURCE: 'ingestion:updateSource',
DELETE_SOURCE: 'ingestion:deleteSource',
MANAGE_SYNC: 'ingestion:manageSync', // Covers triggering, pausing, and forcing syncs
} as const;
const INGESTION_RESOURCES = {
ALL: 'ingestion-source/*',
SOURCE: 'ingestion-source/{sourceId}',
} as const;
// ===================================================================================
// SERVICE: system
// ===================================================================================
const SYSTEM_ACTIONS = {
READ_SETTINGS: 'system:readSettings',
UPDATE_SETTINGS: 'system:updateSettings',
READ_USERS: 'system:readUsers',
CREATE_USER: 'system:createUser',
UPDATE_USER: 'system:updateUser',
DELETE_USER: 'system:deleteUser',
ASSIGN_ROLE: 'system:assignRole',
} as const;
const SYSTEM_RESOURCES = {
SETTINGS: 'system/settings',
USERS: 'system/users',
USER: 'system/user/{userId}',
} as const;
// ===================================================================================
// SERVICE: dashboard
// ===================================================================================
const DASHBOARD_ACTIONS = {
READ: 'dashboard:read',
} as const;
const DASHBOARD_RESOURCES = {
ALL: 'dashboard/*',
} as const;
// ===================================================================================
// EXPORTED DEFINITIONS
// ===================================================================================
/**
* A comprehensive set of all valid IAM actions in the system.
* This is used by the policy validator to ensure that any action in a policy is recognized.
*/
export const ValidActions: Set<string> = new Set([
...Object.values(ARCHIVE_ACTIONS),
...Object.values(INGESTION_ACTIONS),
...Object.values(SYSTEM_ACTIONS),
...Object.values(DASHBOARD_ACTIONS),
]);
/**
* An object containing regular expressions for validating resource formats.
* The validator uses these patterns to ensure that resource strings in a policy
* conform to the expected structure.
*
* Logic:
* - The key represents the service (e.g., 'archive').
* - The value is a RegExp that matches all valid resource formats for that service.
* - This allows for flexible validation. For example, `archive/*` is a valid pattern,
* as is `archive/email/123-abc`.
*/
export const ValidResourcePatterns = {
archive: /^archive\/(all|ingestion-source\/[^\/]+|mailbox\/[^\/]+|custodian\/[^\/]+)$/,
ingestion: /^ingestion-source\/(\*|[^\/]+)$/,
system: /^system\/(settings|users|user\/[^\/]+)$/,
dashboard: /^dashboard\/\*$/,
};

Some files were not shown because too many files have changed in this diff Show More