150 Commits
0.0.3 ... 0.3.4

Author SHA1 Message Date
rustmailer
0bfe379310 update 2026-01-22 15:52:52 +08:00
rustmailer
e47a81d510 Update release.yml 2026-01-22 15:15:19 +08:00
rustmailer
50bdf691dc Update release.yml 2026-01-22 13:42:20 +08:00
rustmailer
11be1e4758 Merge pull request #129 from ItsVRK/feature/125-permissions_nfs_volumes
#125 fix: allow setting of PUID and PGID to prevent permission issues when using NFS mounts or shared volumes
2026-01-22 13:20:54 +08:00
rustmailer
694e5ecbec feat: reset the login password #126 2026-01-22 13:19:21 +08:00
rustmailer
579801ef5c Update mail.tsx 2026-01-22 10:25:52 +08:00
rustmailer
feedb91225 fix: Inbox closed when it is already openend #122 2026-01-22 10:25:13 +08:00
rustmailer
57a6e3c62e fix: Account detail modal doesn't fit in viewport #123 2026-01-22 09:27:28 +08:00
itsvrk
f7fe1f3072 fix: allow setting of PUID and PGID to prevent permission issues when using NFS mounts or shared volumes 2026-01-22 12:12:41 +11:00
rustmailer
8e25b0da14 bump version to 0.3.3 2026-01-21 01:16:58 +08:00
rustmailer
e1f471b8f4 chore: support bulk restore emails 2026-01-21 01:09:38 +08:00
rustmailer
fcd19b1c9f Fix: storage dir creation logic and permissions issues ( #120, #121) 2026-01-21 00:10:07 +08:00
rustmailer
df1a6f8c5b Update README.md 2026-01-20 10:06:13 +08:00
rustmailer
7d150c2982 bump to 0.3.2 2026-01-20 01:25:53 +08:00
rustmailer
9b49005522 fix: "unknown" sender when importing PST #117 2026-01-20 01:22:06 +08:00
rustmailer
1f4e9f7b06 Update nosync-dialog.tsx 2026-01-20 01:21:42 +08:00
rustmailer
d0e4cac229 i18n 2026-01-20 00:53:09 +08:00
rustmailer
30a8d856c1 update 2026-01-19 22:53:11 +08:00
rustmailer
7240be8c31 feat: Separate Docker config file location and email data storage location #81 2026-01-19 22:53:03 +08:00
rustmailer
0d20a9676a feat(search-ui): optimize search UI 2026-01-19 01:52:07 +08:00
rustmailer
48f8092b5a Merge pull request #113 from ktdd/search-improvements-v1
Search improvements
2026-01-14 14:32:38 +08:00
rustmailer
a64351d409 Merge pull request #112 from ktdd/search-table-v1
Replaced email listing with a table with adjustable columns + other changes
2026-01-14 14:32:24 +08:00
rustmailer
a6dd1d19ff Update README.md 2026-01-14 14:01:05 +08:00
rustmailer
f82b20e2fd chore: Set minimum username length to 3 #106 2026-01-14 01:11:01 +08:00
rustmailer
9841038acb chore: Adjust dark mode brightness and light mode saturation #109 2026-01-14 01:04:10 +08:00
rustmailer
97c3db3bd2 chore: default to binding 0.0.0.0 and support binding IPv6 addresses. 2026-01-14 00:27:31 +08:00
rustmailer
82fb2a02bc Merge pull request #110 from op3/feat/support-listening-on-ipv6
Support listening on IPv6 addresses
2026-01-13 23:59:02 +08:00
rustmailer
c61977ce5c Merge pull request #107 from metlos/no-cap-on-sync-interval
Remove the maximum from the sync_interval_min.
2026-01-13 23:51:14 +08:00
rustmailer
106a08fb7e bump verison to 0.3.1 2026-01-13 23:34:46 +08:00
rustmailer
3419506c2e Update README.md 2026-01-13 23:34:17 +08:00
rustmailer
3b040d0cd6 feat: add support for Outlook PST file import #105 2026-01-13 23:32:18 +08:00
rustmailer
5d3c319a67 fix: skip invalid MBOX files during import 2026-01-13 23:31:45 +08:00
ktdd
ad2a43aa35 Search improvements 2026-01-13 12:48:55 +02:00
ktdd
884ae64fc5 Formatting 2026-01-12 12:22:52 +02:00
ktdd
254de35f0e Replaced email listing with a table with adjustable columns. 2026-01-11 18:15:03 +02:00
Oliver Papst
bc3eba5bf7 feat: change default bind address to :: for dual‑stack support
The socket bound to :: accepts both IPv6 and IPv4 (mapped) connections,
so this change enables IPv6 connectivity in addition to the existing
IPv4 behaviour.
2026-01-10 22:36:31 +01:00
Oliver Papst
4dc99b4a84 feat: Add IPv6 support for bichon_bind_ip configuration
Also try to parse the bind_ip address as std::net::Ipv6Addr to accept
both IPv4 and IPv6 addresses. The TcpListener of poem utilizes
ToSocketAddrs trait, which also supports IPv6.
2026-01-10 22:25:31 +01:00
Lukas Krejci
8ce8b9692d Remove the maximum from the sync_interval_min. 2026-01-09 01:35:58 +01:00
rustmailer
3fb761064d Update README.md 2026-01-08 10:52:30 +08:00
rustmailer
54a0a71c44 fix: Missing permission 'user:manage' #102 2026-01-07 23:01:25 +08:00
rustmailer
b490923e17 refactor(search): search filtering and sorting 2026-01-07 16:40:45 +08:00
rustmailer
4ee44daf0d Merge pull request #103 from ktdd/presets-and-sort
Updated presets and added a 'sort by' feature.
2026-01-07 15:03:17 +08:00
ktdd
7edd7c2e35 Updated presets and added a 'sort by' feature. 2026-01-06 12:33:09 +02:00
rustmailer
0c46432150 fix: Inline attachments are not counted as attachments and are not shown when searching for emails with attachments. 2026-01-06 16:28:55 +08:00
rustmailer
d334a23ca7 chore(ui): add attachment file type icon 2026-01-06 16:27:05 +08:00
rustmailer
1768c1a590 fix: Large empty space at the bottom of the screen #98 2026-01-06 14:36:01 +08:00
rustmailer
147f5b4f55 Update README.md 2026-01-05 22:34:24 +08:00
rustmailer
48312dc83e Update README.md 2026-01-05 22:21:53 +08:00
rustmailer
55e97510c4 fix: Folder limit cannot be empty #97 2026-01-05 21:32:00 +08:00
rustmailer
0bf2003670 chore(release): package bichonctl together with bichon binaries 2026-01-05 18:38:34 +08:00
rustmailer
e56fe5ebea feat(mailbox): support mailbox cleanup #96 2026-01-05 18:27:40 +08:00
rustmailer
c69ada32ef feat(ui): add clickable logo to redirect to homepage #95 2026-01-05 14:31:47 +08:00
rustmailer
3f4b37be17 feat(cli): add interactive email import tool for EML, MBOX, and Thunderbird
- Implement `bichonctl` interactive CLI using `dialoguer`.
- Support recursive EML directory scanning with folder structure preservation.
- Support single MBOX file streaming import.
- Support Thunderbird profile import with automatic `.sbd` hierarchy detection.
- Add batch processing (Base64 encoding & batch API requests) for improved performance.
2026-01-05 14:31:04 +08:00
rustmailer
09375ee11c fix: #94 2026-01-01 20:28:23 +08:00
rustmailer
b2e43b0907 fix: skip default admin role validation when global_roles is None #93 2026-01-01 15:24:51 +08:00
rustmailer
44fc0e15de bump versions 2025-12-31 22:57:31 +08:00
rustmailer
ef891b20c3 fix(account): update sync range and handle all-mode reset 2025-12-31 22:57:20 +08:00
rustmailer
a6216c2ce6 feat: support restoring single message to IMAP #77 2025-12-31 22:56:06 +08:00
rustmailer
1c58b516dd update sign-out dialog 2025-12-31 02:44:43 +08:00
rustmailer
ae916574de Fix: modifying the admin user 2025-12-31 02:43:57 +08:00
rustmailer
14fb3368a3 udpate locales files 2025-12-31 02:40:58 +08:00
rustmailer
f49929dd67 Update message.rs 2025-12-30 22:41:51 +08:00
rustmailer
97143d55b8 Merge pull request #67 from mmaudet/feat/envelope-endpoint-and-api-improvements
feat(api): Add envelope endpoint and improve API documentation
2025-12-30 22:32:26 +08:00
rustmailer
e666f76d87 Merge branch 'main' into feat/envelope-endpoint-and-api-improvements 2025-12-30 22:32:11 +08:00
rustmailer
7fb6575f8d feat: use email 'Date' header for statistics and search filtering #87 2025-12-30 22:21:53 +08:00
rustmailer
fb0be8c5d1 bump version to 0.2.1 2025-12-30 15:11:57 +08:00
rustmailer
455e6b1a75 feat: support user appearance preferences with persisted theme and language #85 2025-12-30 15:09:41 +08:00
rustmailer
75cae51be9 feat(ui): Add quick page navigation to the email list pagination #85 2025-12-30 11:40:09 +08:00
rustmailer
62d956c7d6 feat: increase password max to 256, fix i18n, and force re-login #83
- Raise password maximum length from 32 to 256 characters
- fix profileSchema to accept `t` for proper internationalization
- Invalidate user's WebUI token on password change, requiring re-login
2025-12-30 11:05:07 +08:00
rustmailer
c01872284e Update release.yml 2025-12-29 12:41:15 +08:00
rustmailer
2887b5d16d Update release.yml 2025-12-29 12:37:51 +08:00
rustmailer
558ea2f9b0 Update release.yml 2025-12-29 12:25:05 +08:00
rustmailer
b07defa2d5 Update README.md 2025-12-29 12:22:05 +08:00
rustmailer
76ab16b55b fetch: support fetching mails before a specified date 2025-12-29 12:06:04 +08:00
rustmailer
06a126461b feat: Replace min/max byte inputs with size preset selection #39 2025-12-28 13:54:35 +08:00
rustmailer
a02bb65ca0 feat: Search results display the account email and mailbox name. #39 2025-12-28 13:20:21 +08:00
rustmailer
1f57f372d3 feat: Add sync_batch_size to allow users to customize the synchronization batch size, and introduce date_before to support semantics such as downloading emails from more than one year ago. #24 #58 2025-12-28 13:01:34 +08:00
rustmailer
b35493e4e1 chore(search ui): Quick selection of year and month #39 2025-12-28 12:58:30 +08:00
rustmailer
16578fb8e2 fix: stitch adjacent RFC2047 words to prevent byte-split artifacts #79 2025-12-27 03:38:53 +08:00
rustmailer
6dd3f90ee0 update 2025-12-26 20:04:31 +08:00
rustmailer
6d11dcd33f Merge pull request #65 from mmaudet/fix/rename-id-to-message-id
fix(api): Rename id to message_id and fix OpenAPI path parameters
2025-12-26 20:00:48 +08:00
rustmailer
e64c2467fd Merge branch 'main' into fix/rename-id-to-message-id 2025-12-26 19:59:25 +08:00
rustmailer
e8a15695d8 feat(ui): add i18n support for profile dropdown 2025-12-26 14:45:43 +08:00
rustmailer
0f3ad83004 feat: use password file as primary source if provided 2025-12-26 14:44:49 +08:00
rustmailer
4af5176b65 feat: add multi-user support and role-based access control #31 2025-12-26 14:27:04 +08:00
rustmailer
1e2f526a07 fix: ensure unselected checkboxes are visible in dark mode #70 2025-12-26 14:17:55 +08:00
rustmailer
97be76278e Merge pull request #71 from metlos/encrypt-password-file
feat(cli): add an option to specify the encrypt password in a file
2025-12-20 19:45:55 +08:00
rustmailer
d4232789f9 Add roadmap section to README #76
Added a roadmap section outlining future features and enhancements.
2025-12-20 18:59:52 +08:00
Lukas Krejci
9b83d5617e feat(cli): add an option to specify the encrypt password in a file 2025-12-17 18:03:00 +01:00
Michel-Marie MAUDET
70db81dc03 feat(api): Add envelope endpoint and improve API documentation
- Add GET /envelope/{account_id}/{message_id} endpoint to retrieve message envelope (metadata)
- Add get_envelope_by_id method to ENVELOPE_INDEX_MANAGER for querying single envelope
- Move message_id from query parameter to path parameter for clearer API paths:
  - /message-content/{account_id}/{message_id}
  - /download-message/{account_id}/{message_id}
  - /download-attachment/{account_id}/{message_id}
  - /envelope/{account_id}/{message_id}
- Fix API documentation descriptions to be more accurate:
  - search_messages: Now correctly describes search functionality
  - get_thread_messages: Mentions thread_id requirement
  - proxy endpoints: Fixed copy-paste errors from OAuth2 docs
- Update frontend API client to use new path-based URLs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 11:12:16 +01:00
Michel-Marie MAUDET
6b18d7371d fix(api): Use poem_openapi::param::Path for OpenAPI documentation
- Fix Path import in message.rs, account.rs, mailbox.rs, oauth2.rs,
  and auto_config.rs to use poem_openapi::param::Path instead of
  poem::web::Path
- This ensures path parameters appear in OpenAPI/Swagger documentation
- Update frontend API calls to use message_id parameter
- Add parameter documentation comments for better API clarity

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 09:33:18 +01:00
Michel-Marie MAUDET
934e81c5f9 fix(api): Rename query parameter id to message_id for clarity
Rename the `id` query parameter to `message_id` in three message API
endpoints for better API clarity and consistency:

- GET /api/v1/message-content/:account_id
- GET /api/v1/download-message/:account_id
- GET /api/v1/download-attachment/:account_id

This is a breaking change for API clients that use these endpoints.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 08:02:02 +01:00
rustmailer
c05a8944ef Merge pull request #57 from pansuzuki/lang-pack-pl
Added new language Polish
2025-12-10 23:13:04 +08:00
Marcin Wojtczak
eaff2ca70d Added new language Polish 2025-12-10 00:21:16 +01:00
rustmailer
000d144e70 chore(ui): Adjust width of account list for better visibility 2025-12-08 03:43:00 +08:00
rustmailer
20970b4fb6 Feat: Add IMAP connection pool status logging and disable bb8 idle timeout 2025-12-08 03:41:56 +08:00
rustmailer
57df3466e7 Update extractor.rs 2025-12-08 02:18:34 +08:00
rustmailer
75d859abdf fix(sync): error in account sync task "TooNarrow" #38 2025-12-08 01:30:46 +08:00
rustmailer
b0412b02f5 Add user case showcase and performance data overview
Added a user case showcase highlighting performance and storage efficiency with data from 126 email accounts.
2025-12-07 23:22:54 +08:00
rustmailer
a1453f3cda Document environment variable quotes in container usage
Added notes on running Bichon in a container to README.
2025-12-07 00:42:10 +08:00
rustmailer
9e656b1f91 Bump version from 0.1.3 to 0.1.4 2025-12-07 00:22:41 +08:00
rustmailer
afac192280 Enhance CORS configuration section in README
Added detailed CORS configuration instructions for Bichon, including new behavior in v0.1.4 and examples for setting origins.
2025-12-07 00:22:07 +08:00
rustmailer
c00ffe8d11 feat(cors): remove default value for BICHON_CORS_ORIGINS and allow all origins when unset
- Changed behavior so that when BICHON_CORS_ORIGINS is not configured, CORS now allows any origin.
- Added debug logging to print incoming Origin and configured origins to help users diagnose CORS misconfiguration issues.
2025-12-07 00:03:21 +08:00
rustmailer
c90a2d552d Fix FAQ link in README.md 2025-12-06 22:04:35 +08:00
rustmailer
a4627a63cb Update README.md 2025-12-06 22:03:36 +08:00
rustmailer
684b9765fe Update README.md 2025-12-06 20:33:51 +08:00
rustmailer
b0ce7671a8 chore(ui): move name field to step 2 #30 2025-12-04 08:33:16 +08:00
rustmailer
4b13bf14c4 Fix(sync): missing initial sync start time after enabling a previously disabled account #32 2025-12-03 20:11:19 +08:00
rustmailer
0015192b4d bump version to 0.1.3 2025-12-03 09:27:17 +08:00
rustmailer
2f3acdd759 fix(ui, config): Ensure use_dangerous status is visible in ui 2025-12-03 09:26:34 +08:00
rustmailer
0fc83b693e fix(i18n, dashboard): Internationalize recent activity chart dates 2025-12-03 09:23:51 +08:00
rustmailer
9ba56ca5bb feat(i18n): Implement internationalization for date distance 2025-12-03 09:23:16 +08:00
rustmailer
8f7244ccb9 fix(account): Resolve name clearing issue and update field labels 2025-12-03 09:21:33 +08:00
rustmailer
454950374f feat(account): Prioritize dedicated name field for IMAP authentication username #28
Refactor account authentication logic to prioritize the `name` field over the `email` field during the IMAP connection phase.

- The `name` field now serves as the primary IMAP login credential and is no longer treated purely as an optional, descriptive field.
- If the `name` field is empty or unset, the system will fall back to using the full `email` address for authentication.
- This change supports IMAP providers that require a username different from the full email address (e.g., employee ID, specific account name).
2025-12-03 03:48:39 +08:00
rustmailer
3a2b42f5c3 fixZ: Send IMAP ID command after successful authentication to ensure compatibility with 163 mail servers #25 2025-11-30 17:40:10 +08:00
rustmailer
34ec3a7d5b bump versions 2025-11-30 05:53:39 +08:00
rustmailer
82397ab0cd fix(ui): fix sync folder selection jump issue; add auto-select children/parents and expand/collapse all folders button #21 2025-11-30 05:51:17 +08:00
rustmailer
1cfc12324f fix(ui): Handle IMAP connection failure gracefully during folder sync #23 2025-11-29 11:42:11 +08:00
rustmailer
dffdac3eb6 Update README.md 2025-11-29 01:24:00 +08:00
rustmailer
7d02e58e4e Update README.md 2025-11-29 00:35:16 +08:00
rustmailer
847cc6825a Update README.md 2025-11-29 00:34:22 +08:00
rustmailer
6ce1420714 feat(import): introduce /api/v1/import endpoint to support batch EML email import 2025-11-27 22:11:05 +08:00
rustmailer
9a72ce9154 update 2025-11-27 15:18:12 +08:00
rustmailer
9f713ef044 update 2025-11-27 15:12:31 +08:00
rustmailer
275cde180b update issue templates 2025-11-27 15:01:14 +08:00
rustmailer
c82fb4f301 Update issue templates 2025-11-27 14:49:37 +08:00
rustmailer
736270b08c feat(dashboard): Display system version and Git hash, link to release tag #19 2025-11-27 03:55:20 +08:00
rustmailer
855c9a1ffc update 2025-11-27 02:01:09 +08:00
rustmailer
b845eda58a update 2025-11-27 01:49:20 +08:00
rustmailer
6571966228 update 2025-11-27 01:36:42 +08:00
rustmailer
c727460251 update 2025-11-27 01:27:16 +08:00
rustmailer
7c7e353114 feat: add option to trust any TLS certificate for IMAP connections 2025-11-27 00:57:43 +08:00
rustmailer
78b33f8994 Update README.md 2025-11-26 22:28:54 +08:00
rustmailer
324051bdf0 Revise README to enhance project description
Updated README to clarify Bichon's purpose and features.
2025-11-26 20:40:40 +08:00
rustmailer
33a16c1af9 Merge branch 'main' of https://github.com/rustmailer/bichon 2025-11-26 09:35:29 +08:00
rustmailer
1bfecc2ff1 update 2025-11-26 09:21:42 +08:00
rustmailer
a8282a95f3 Update README.md 2025-11-25 08:33:22 +08:00
rustmailer
9fe617aed7 Revise internationalization details in README
Updated internationalization section to clarify language support.
2025-11-25 00:11:10 +08:00
rustmailer
22e12b68a8 Add internationalization section to README
Added internationalization support details to README.
2025-11-25 00:08:50 +08:00
rustmailer
c6ee92aa3d Update thread-dialog.tsx 2025-11-24 22:44:46 +08:00
rustmailer
089b6885a7 fix(account): update "disabled" semantics
Modified the meaning of "disabled" accounts: they no longer connect to the IMAP server for syncing, but existing data remains accessible for search and queries.
2025-11-24 22:34:40 +08:00
rustmailer
e41b26ba0b update 2025-11-24 22:33:16 +08:00
rustmailer
ea81d0298b feat(i18n): add language switcher in top-right corner for multi-language support #9 2025-11-24 21:51:16 +08:00
rustmailer
5ebc7394d0 fix(account): prevent IMAP password from being overwritten when editing account #7 2025-11-24 19:11:52 +08:00
rustmailer
b97f2666a0 Revise CORS settings and access instructions
Updated CORS configuration and access instructions in README.
2025-11-23 01:30:56 +08:00
rustmailer
b36e63f530 Document Bichon encryption password setup
Added instructions for setting the Bichon encryption password via command-line and environment variable.
2025-11-23 00:43:41 +08:00
rustmailer
d4dfee8649 Merge pull request #10 from n3storm/patch-1
Update README with Bichon command requirements
2025-11-22 17:05:31 +08:00
rustmailer
1042fad6d0 feat: Add folder sync selection and All Mail exclusion logic 2025-11-22 17:02:20 +08:00
rustmailer
1e9a5fde51 bump versions 2025-11-22 17:01:39 +08:00
Néstor Díaz Valencia
c586bab84e Update README with Bichon command requirements
Added information about requirements for --bichon-root-dir and --bichon-cors-origins arguments when binary deploying.
2025-11-22 09:49:44 +01:00
341 changed files with 54357 additions and 8149 deletions

21
.github/ISSUE_TEMPLATE/bug.md.md vendored Normal file
View File

@@ -0,0 +1,21 @@
---
name: Bug Report
about: Report a problem you encountered
title: "[BUG] "
labels: ["bug"]
assignees: ""
---
> **Please write and communicate in English.**
### Version
Which version are you using?
### Steps to Reproduce
Describe the steps to reproduce the issue clearly.
### Issue Description
What is the problem you encountered?
### Screenshots or Logs (optional)
Attach any screenshots or logs if available.

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

18
.github/ISSUE_TEMPLATE/feature.md vendored Normal file
View File

@@ -0,0 +1,18 @@
---
name: Feature Request
about: Suggest a new feature or improvement
title: "[FEATURE] "
labels: ["enhancement"]
assignees: ""
---
> **Please write and communicate in English.**
### Description
What feature would you like to see?
### Purpose / Use Case
Why is this feature needed? What problem does it solve?
### Additional Information (optional)
Any extra ideas or context.

15
.github/ISSUE_TEMPLATE/other.md vendored Normal file
View File

@@ -0,0 +1,15 @@
---
name: Other Issue
about: Any other question or topic
title: ""
labels: ["question"]
assignees: ""
---
> **Please write and communicate in English.**
### Description
Describe your question or topic.
### Additional Information (optional)
Provide any additional context if needed.

View File

@@ -6,6 +6,8 @@ on:
- '[0-9]+.[0-9]+.[0-9]+'
env:
BINARY_NAME: bichon
BINARY_CTL: bichonctl
BINARY_ADMIN: bichon-admin
permissions:
contents: write
@@ -34,6 +36,22 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Verify Cargo.toml version matches git tag
shell: bash
run: |
TAG_VERSION="${GITHUB_REF_NAME}"
CARGO_VERSION=$(grep '^version' Cargo.toml | head -n1 | cut -d '"' -f2)
echo "Git tag version: $TAG_VERSION"
echo "Cargo.toml version: $CARGO_VERSION"
if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then
echo "::error::Version mismatch! Git tag ($TAG_VERSION) does not match Cargo.toml version ($CARGO_VERSION)"
exit 1
fi
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
@@ -75,6 +93,8 @@ jobs:
if: matrix.os != 'windows-latest' && matrix.target != 'aarch64-unknown-linux-gnu'
run: |
strip target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}
strip target/${{ matrix.target }}/release/${{ env.BINARY_CTL }}
strip target/${{ matrix.target }}/release/${{ env.BINARY_ADMIN }}
- name: Pack artifact (Linux/macOS)
if: matrix.os != 'windows-latest'
@@ -83,7 +103,9 @@ jobs:
mkdir -p release
BINARY="target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}"
cp README.md LICENSE release/
cp "$BINARY" release/
cp target/${{ matrix.target }}/release/${{ env.BINARY_NAME }} release/
cp target/${{ matrix.target }}/release/${{ env.BINARY_CTL }} release/
cp target/${{ matrix.target }}/release/${{ env.BINARY_ADMIN }} release/
tar -czvf "${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.tar.gz" -C release .
mv "${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.tar.gz" release/
@@ -99,10 +121,14 @@ jobs:
shell: pwsh
run: |
mkdir -p release
$BINARY = "target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}.exe"
Copy-Item -Path $BINARY -Destination release/
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}.exe" release/
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_CTL }}.exe" release/
Copy-Item "target/${{ matrix.target }}/release/${{ env.BINARY_ADMIN }}.exe" release/
Copy-Item -Path README.md -Destination release/
Copy-Item -Path LICENSE -Destination release/
Compress-Archive -Path release\* -DestinationPath "release/${{ env.BINARY_NAME }}-${{ github.ref_name }}-${{ matrix.target }}.zip" -Force
- name: Upload build artifact

2396
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,20 @@
[package]
name = "bichon"
version = "0.0.3"
version = "0.3.4"
edition = "2021"
[[bin]]
name = "bichon"
path = "src/main.rs"
[[bin]]
name = "bichonctl"
path = "src/bin/bichonctl.rs"
[[bin]]
name = "bichon-admin"
path = "src/bin/bichon_admin.rs"
[features]
default = []
@@ -19,8 +27,8 @@ opt-level = 3
codegen-units = 1
[dependencies]
chrono = "0.4.42"
clap = { version = "4.5.51", features = ["derive", "env"] }
chrono = "0.4.43"
clap = { version = "4.5.54", features = ["derive", "env"] }
mimalloc = "0.1.48"
native_db = "0.8.2"
itertools = "0.14.0"
@@ -37,30 +45,30 @@ poem-openapi = { version = "5.1.16", features = [
] }
ring = { version = "0.17.14", features = ["std"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
tokio = { version = "1.48.0", features = ["full"] }
tracing = "0.1.41"
serde_json = "1.0.149"
tokio = { version = "1.49.0", features = ["full"] }
tracing = "0.1.44"
tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] }
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
base64 = "0.22.1"
snafu = "0.8.9"
reqwest = { version = "0.12.24", default-features = false, features = [
"json",
"stream",
"native-tls",
# "rustls-tls",
#"native-tls",
"rustls-tls",
"blocking",
"socks",
] }
tokio-socks = "0.5.2"
http = "1.3.1"
http = "1.4.0"
regex = "1.12.2"
email_address = "0.2.9"
futures = "0.3.31"
utf7-imap = "0.3.2"
imap-proto = "0.16.6"
mail-parser = { version = '0.11.1', features = ["serde"] }
mail-send = "0.5.2"
# mail-send = "0.5.2"
tokio-rustls = { version = "0.26.4", default-features = false, features = [
"ring",
"tls12",
@@ -68,7 +76,7 @@ tokio-rustls = { version = "0.26.4", default-features = false, features = [
timeago = "0.5.0"
ahash = "0.8.12"
oauth2 = { version = "5.0.0", features = ["reqwest-blocking"] }
url = { version = "2.5.7", features = ["serde"] }
url = { version = "2.5.8", features = ["serde"] }
sysinfo = "0.37.2"
num_cpus = "1.17.0"
cacache = { version = "13.1.0", default-features = false, features = [
@@ -81,22 +89,22 @@ async-imap = { version = "0.11.1", default-features = false, features = [
"runtime-tokio",
"compress",
] }
webpki-roots = "1.0.4"
rustls = { version = "0.23.35", default-features = false, features = ["ring"] }
rustls-pki-types = "1.13.0"
webpki-roots = "1.0.5"
rustls = { version = "0.23.36", default-features = false, features = ["ring"] }
rustls-pki-types = "1.14.0"
tokio-io-timeout = "1.2.1"
bb8 = "0.9.0"
bb8 = "0.9.1"
semver = "1.0.27"
governor = "0.10.2"
lru = "0.16.2"
governor = "0.10.4"
lru = "0.16.3"
mime_guess = "2.0.5"
hex = "0.4.3"
time = { version = "0.3.44", features = [
time = { version = "0.3.45", features = [
"formatting",
"parsing",
"local-offset",
] }
rust-embed = "8.9.0"
rust-embed = "8.11.0"
murmur3 = "0.5.2"
autoconfig = "0.4.0"
urlencoding = "2.1.3"
@@ -105,10 +113,18 @@ dashmap = "6.1.0"
openssl-sys = { version = "0.9.111", optional = true, features = ["vendored"] }
gethostname = "1.1.0"
tantivy = { version = "0.25.0", features = ["quickwit", "zstd-compression"] }
itoa = "1.0.15"
html2text = "0.16.2"
itoa = "1.0.17"
html2text = "0.16.6"
bytes = "1.11.0"
dialoguer = "0.12.0"
console = "0.16.2"
toml = "0.9.8"
memmap2 = "0.9.9"
outlook-pst = { git = "https://github.com/rustmailer/outlook-pst-rs.git", branch = "main" }
compressed-rtf = "1.0.0"
codepage-strings = "1.0.2"
mail-send = "0.5.2"
[dev-dependencies]
#bincode = "1.3.3"
#secret-lib = "1.0.0"
tempfile = "3.23.0"
tempfile = "3.24.0"

390
README.md
View File

@@ -22,7 +22,7 @@
<img src="https://img.shields.io/badge/license-AGPLv3-blue.svg" alt="License">
</a>
<a href="https://deepwiki.com/rustmailer/bichon"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
<a href="https://discord.gg/evFnSpdpaE">
<a href="https://discord.gg/Bq4M2cDmF4">
<img src="https://img.shields.io/badge/Discord-Join%20Server-7289DA?logo=discord&logoColor=white" alt="Discord">
</a>
<a href="https://x.com/rustmailer">
@@ -31,50 +31,41 @@
</p>
</div>
Bichon is a minimal, high-performance, standalone Rust email archiver with a built-in WebUI.
Its name is inspired by the puppy my daughter adopted last month.
It runs as a single binary, requires no external dependencies, and provides fast, efficient email archiving, management, and search.
Bichon is an open-source email archiving system that **synchronizes emails from IMAP servers**, **indexes them for full-text search**, and provides a **REST API** for programmatic access.
**Unlike email clients**, Bichon is designed for **archiving and searching** rather than sending/receiving emails. It runs as a **standalone server application** that continuously synchronizes configured email accounts and maintains a **searchable local archive**.
Built in Rust, it requires no external dependencies and provides fast, efficient email archiving, management, and search through a built-in WebUI. Its name is inspired by the puppy my daughter adopted last month.
## Key Differences from Email Clients
### Core Comparison
| Feature | Email Clients | Bichon |
|---------|---------------|--------|
| **Primary Purpose** | Send/receive emails, real-time communication | Archive, search, manage historical emails |
| **Sending Capability** | ✅ Supports sending emails | ❌ No email sending support |
| **Runtime Mode** | Desktop/mobile applications | Server-side application |
| **Data Storage** | Local cache + server | Local archive store |
| **Search Capability** | Basic search | Full-text indexing, advanced search |
| **API Interface** | Typically not provided | Complete REST API |
| **Multi-account Management** | Limited | Supports unified search across accounts |
## 🚀 Features
### ⚡ Lightweight & Standalone
- Pure Rust, single-machine application.
- No external database required.
- Includes **WebUI** for intuitive management.
### 📬 Multi-Account Management
- Synchronize and download emails from multiple accounts.
- Flexible selection: by **date range**, **number of emails**, or **specific mailboxes**.
### 🔑 IMAP & OAuth2 Authentication
- Supports **IMAP password** or **OAuth2** login.
- Built-in WebUI for **OAuth2 authorization**, including **automatic token refresh** (e.g., Gmail, Outlook).
- Supports **network proxy** for IMAP and OAuth2.
- Automatic IMAP server discovery and configuration.
### 🔍 Unified Multi-Account Search
- Powerful search across all accounts:
**account**, **mailbox**, **sender**, **attachment name**, **has attachments**, **size**, **date**, **subject**, **body**.
### 🏷️ Tags & Facets
- Organize archived emails using **tags** backed by Tantivy **facets**.
- Efficiently filter and locate emails based on these facet-based tags.
### 💾 Compressed & Deduplicated Storage
- Store emails efficiently with **transparent compression** and **deduplication**—emails can be read directly without any extra steps.
### 📂 Email Management & Viewing
- Bulk cleanup of local archives.
- Download emails as **EML** or **attachments separately**.
- View and browse emails directly.
- View the full **conversation thread** of any email.
### 📊 Dashboard & Analytics
- Visualize email statistics: **counts**, **time distribution**, **top senders**, **largest emails**, **account rankings**.
### 🛠️ OpenAPI Support
- Provides **OpenAPI documentation**.
- **Access token authentication** for programmatic access.
* **Lightweight & Standalone** — Pure Rust, no external database, with built-in WebUI
* **Multi-Account Sync** — Download and manage emails from multiple accounts
* **Flexible Fetching** — Sync by date range, email count, or specific mailboxes
* **IMAP & OAuth2 Auth** — Password or OAuth2 login with automatic token refresh
* **Proxy & Auto Config** — Supports network proxies and automatic IMAP discovery
* **Unified Search** — Search across all accounts by sender, subject, body, date, size, attachments, and more
* **Tags & Facets** — Organize emails using Tantivy facet-based tags
* **Compressed Storage** — Transparent compression and deduplication for efficient storage
* **Email Management** — Browse, view threads, bulk clean up, export EML or attachments
* **Dashboard & Analytics** — Visual insights into email volume, trends, and top senders
* **Internationalized WebUI** — Frontend available in 18 languages
* **OpenAPI Access** — OpenAPI docs with access-token authentication
* **Multi-User & Role-Based Access Control (RBAC)** — Supports multiple users with fine-grained, role-based permissions
* **Email Import (EML, MBOX & PST)** — Import existing mail archives via the bichonctl CLI
## 🐾 Why Create Bichon?
@@ -101,9 +92,13 @@ Its not perfect, but I hope it brings you value.
<img width="1909" height="904" alt="image" src="https://github.com/user-attachments/assets/ab4bf6ae-faa6-4b49-ae39-705eb9d4487f" />
<img width="1910" height="910" alt="image" src="https://github.com/user-attachments/assets/bcf9cca2-d690-4e7b-b2c9-c52a31c7b999" />
<img width="1915" height="903" alt="image" src="https://github.com/user-attachments/assets/242817d7-3e12-4cbb-afb0-c5ef7366178d" />
<img width="1920" height="910" alt="image" src="https://github.com/user-attachments/assets/14561b74-ed53-4017-9c5b-a64920ec3526" />
<img width="1910" height="1055" alt="image" src="https://github.com/user-attachments/assets/9bde665e-7717-447f-ad29-f743a32a4dc0" />
<img width="1913" height="909" alt="image" src="https://github.com/user-attachments/assets/6fd54cb0-c86f-4ceb-a955-c81107614fc4" />
<img width="1916" height="814" alt="image" src="https://github.com/user-attachments/assets/6a079d98-ff6c-46f4-9ec6-e76d320bff5d" />
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=rustmailer/bichon&type=date&legend=top-left)](https://www.star-history.com/#rustmailer/bichon&type=date&legend=top-left)
## 🚀 Quick Start
@@ -117,36 +112,146 @@ docker pull rustmailer/bichon:latest
# Create data directory
mkdir -p ./bichon-data
# Optional: Set PUID and PGID to match your host user for proper file permissions
# Find your user ID with: id $USER
# This prevents permission issues when using NFS mounts or shared volumes
# Run container
docker run -d \
--name bichon \
-p 15630:15630 \
-v $(pwd)/bichon-data:/data \
-e PUID=1000 \
-e PGID=1000 \
-e BICHON_LOG_LEVEL=info \
-e BICHON_ROOT_DIR=/data \
rustmailer/bichon:latest
# Optional: For custom storage configuration with separate volumes
docker run -d \
--name bichon \
-p 15630:15630 \
-v $(pwd)/bichon-data:/data \
-v $(pwd)/envelope:/envelope \
-v $(pwd)/eml:/eml \
-e PUID=1000 \
-e PGID=1000 \
-e BICHON_ROOT_DIR=/data \
-e BICHON_INDEX_DIR=/envelope \
-e BICHON_DATA_DIR=/eml \
rustmailer/bichon:latest
```
* If you are accessing Bichon on the same machine where it is installed (Machine A), open:
```
http://localhost:15630
```
## CORS Configuration (Important for Browser Access)
* If you are accessing Bichon from another machine (Machine B), make sure to set CORS with the IP of Machine B, for example:
Starting from **v0.1.4**, Bichon changes how `BICHON_CORS_ORIGINS` works:
### **🔄 New Behavior in v0.1.4**
* If **`BICHON_CORS_ORIGINS` is not set**, Bichon now **allows all origins**.
This makes local testing and simple deployments much easier.
* If you **do set** `BICHON_CORS_ORIGINS`, then **you must explicitly list each allowed origin**.
* `*` is **not supported** and will **not work** — you must provide exact URLs.
#### How CORS Matching Works
When a browser accesses Bichon, it will send an `Origin` header.
* **Incoming Origin** = the exact address the browser is using
* **Configured origins** = the list you passed to `BICHON_CORS_ORIGINS`
If Configured origins does not contain the Incoming Origin exactly as a full string match, the browser request will be rejected.
Example debug log:
```
2025-12-06T23:56:30.422+08:00 DEBUG bichon::modules::rest: CORS: Incoming Origin = "http://localhost:15630"
2025-12-06T23:56:30.422+08:00 DEBUG bichon::modules::rest: CORS: Configured origins = ["http://192.168.3.2:15630"]
```
In this example:
* Browser is using `http://localhost:15630`
* But the configured origin is `http://192.168.3.2:15630`
→ **CORS will fail**, and you can immediately see why.
#### When Should You Configure CORS?
It is strongly recommended to configure CORS in production environments to ensure that only trusted browser origins can access Bichon.
If you want to access Bichon from a browser:
* Add the exact **IP** with port
* Or the exact **hostname** with port
* Or the **domain** (port optional if it's 80)
Examples:
```
http://192.168.1.16:15630
http://myserver.local:15630
http://mydomain.com
```
If you access Bichon in **multiple different ways**, list all of them:
```
-e BICHON_CORS_ORIGINS="http://192.168.1.16:15630,http://myserver.local:15630,http://mydomain.com"
```
> **Do not add a trailing slash**
> (`http://192.168.1.16:15630/` will not match)
>
> **Do not use `*`**, it is not supported.
#### How to Enable Debug Logs (Highly Recommended for CORS Issues)
Set environment variable:
```
BICHON_LOG_LEVEL=debug
```
Or via command-line:
```
--bichon-log-level debug
```
Default is `info`, so CORS logs will not appear unless debug logging is enabled.
---
#### ⚠️ Note on Running Bichon in a Container
> ⚠️ **Note:** If you are running Bichon in a container (via **Docker Compose** or **docker run**), be careful with **quotes in environment variable values**.
For example, **do not** write:
```bash
# Run container
docker run -d \
--name bichon \
-p 15630:15630 \
-v $(pwd)/bichon-data:/data \
-e BICHON_LOG_LEVEL=info \
-e BICHON_ROOT_DIR=/data \
-e BICHON_CORS_ORIGINS="http://localhost:15630,http://B_MACHINE_IP:15630,*" \
rustmailer/bichon:latest
-e BICHON_CORS_ORIGINS="http://localhost:15630,http://myserver.local:15630"
```
Access instructions:
This allows Machine B to access the Bichon interface on Machine A via a browser.
* The outer quotes (`"`) will be passed literally into the container and may cause CORS misconfiguration.
**Correct way:**
```bash
-e BICHON_CORS_ORIGINS=http://localhost:15630,http://myserver.local:15630
```
Or using YAML literal style for Docker Compose:
```yaml
environment:
BICHON_CORS_ORIGINS: |
http://localhost:15630,http://myserver.local:15630
```
This ensures that the configured origins are interpreted correctly inside the container.
> ⚠️ **Note:** This fucking problem I actually didnt know about myself; thanks to [gall-1](https://github.com/gall-1) for pointing it out.
### Binary Deployment
@@ -167,59 +272,134 @@ Extract and run:
.\bichon.exe --bichon-root-dir e:\bichon-data
```
## 🔑 Root User Login Information
* --bichon-root-dir argument is required and must be an absolute path.
**Bichon currently supports a single Root user login for system access and management.**
* If you are accessing Bichon from a proxy domain **mydomain** argument --bichon-cors-origins="https://mydomain" is required.
## 🔐 Setting the Bichon Encryption Password
### First Login and Enabling Access
Please refer to the following documentation for detailed instructions on how to set the Bichon encryption password:
To enable the login feature, you must specify a command-line argument or set an environment variable when starting Bichon.
👉 [https://github.com/rustmailer/bichon/wiki/Setting-the-Bichon-Encryption-Password](https://github.com/rustmailer/bichon/wiki/Setting-the-Bichon-Encryption-Password)
#### 1\. Command-Line Argument
All configuration methods, including command-line options, environment variables, and password file support (v0.2.0+), are documented there.
Add the `--bichon-enable-access-token` flag to your startup command:
## 🔑 User Authentication & Admin Account
```bash
# Linux/macOS Binary Deployment Example
./bichon --bichon-root-dir /tmp/bichon-data --bichon-enable-access-token
```
Starting from **Bichon v0.2.0**, the authentication model has been updated.
#### 2\. Environment Variable (Recommended for Docker)
### Built-in Admin User (v0.2.0+)
Set the environment variable `BICHON_ENABLE_ACCESS_TOKEN` to `true`:
* Bichon no longer uses the legacy single-account `root / root` login.
* The system now ships with a built-in **admin** user by default.
* **Default credentials:**
```bash
# Docker Deployment Example
docker run -d \
--name bichon \
-p 15630:15630 \
-v $(pwd)/bichon-data:/data \
-e BICHON_LOG_LEVEL=info \
-e BICHON_ROOT_DIR=/data \
-e BICHON_ENABLE_ACCESS_TOKEN=true \
rustmailer/bichon:latest
```
* **Username:** `admin`
* **Password:** `admin@bichon`
### Default Credentials
> The legacy `root` account and the `root / root` default credentials **no longer exist**.
* **Initial Login Account:** `root`
* **Initial Password:** `root`
### Changing the Password
### Mandatory Access Token Authentication
**It is strongly recommended that you change the default password immediately after your first login.**
* From **v0.2.0 onward**, **access-tokenbased authentication is always enabled**.
* The startup flag and environment variable
`--bichon-enable-access-token` / `BICHON_ENABLE_ACCESS_TOKEN`
are **deprecated and no longer used**.
* No additional configuration is required to enable authentication.
You can change the password via the WebUI:
1. Log in to the WebUI.
2. Navigate to the **Settings** page.
3. Use the **Reset Root Password** option to modify your password.
### Managing Account Information
After logging in, the admin user can manage their profile directly in the WebUI:
1. Log in to the WebUI using the default admin credentials.
2. Navigate to **Settings → Profile**.
3. Update:
* Username
* Password
* Avatar and other profile information
⚠️ **Security Notice:**
For security reasons, you should **change the default admin password immediately after the first login**.
## 📦 Import Existing Mail Archives
If you already have existing emails stored as **EML** or **MBOX** files, you can import them into Bichon using the `bichonctl` CLI.
This allows you to:
- Index historical emails
- Perform full-text search immediately
- Manage imported data just like synced IMAP emails
📖 **Full documentation:**
👉 https://github.com/rustmailer/bichon/wiki/Using-Bichonctl-For-Email-Import
## 📖 Documentation
> Under construction. Documentation will be available soon.
[Bichon Wiki](https://github.com/rustmailer/bichon/wiki).
## FAQ
please see the FAQ in the project Wiki:
👉 [https://github.com/rustmailer/bichon/wiki/FAQ](https://github.com/rustmailer/bichon/wiki/FAQ-(Frequently-Asked-Questions))
## 💡 User Case Showcase
We have collected a real-world case study from a user processing email data, which demonstrates Bichon's performance and storage efficiency in a live environment.
This case involves ingesting and indexing data from **126 email accounts**. The total original data volume was **229 GB**, comprising **460,000 emails**.
### 📊 Performance Data Overview
<img width="945" height="582" alt="image" src="https://github.com/user-attachments/assets/934ed6dd-c1da-4483-84fa-6d5b1bf6ca72" />
A special thank you to **[@rallisf1](https://github.com/rallisf1)** for sharing this usage scenario and the detailed data.
#### 🤝 Open Invitation
This data is provided solely as a **reference** for real-world usage. We encourage more users to share their Bichon usage screenshots and metrics (e.g., ingestion volume, compression ratio, search speed, etc.) to help the community conduct a more comprehensive assessment of Bichon's suitability and performance.
---
## Roadmap
* [x] Multi-user support with account/password login
* [x] System-level roles (admin / user)
* [x] Per-mail-account permissions
* [x] `bichonctl` command-line tool
* [x] Import emails from `eml`, `mbox`, `pst` (Single file)
* [ ] Import emails from `msg`
* [ ] Manual sync controls
* Sync on demand
* Sync a single folder
* Verify completeness by comparing with the mail server
* [ ] Post-sync server cleanup
* Clean up server-side emails after successful sync
* Free up mailbox space (e.g. Gmail)
* [ ] Email export
* Export by folder
* Export by entire account
* [ ] Account-to-account email sync
* Sync emails to a specified target account
* Support mailbox migration
---
## 🛠️ Tech Stack
@@ -231,10 +411,14 @@ You can change the password via the WebUI:
- Used exclusively for lightweight configuration and account metadata.
- **Email Protocols**: IMAP (Supports standard Password & OAuth2)
## 🤝 Contributing
Issues and Pull Requests are welcome!
Contributions of all kinds are welcome!
Whether youd like to submit code, report a bug, or share practical suggestions that can help improve the project, your input is highly appreciated.
Feel free to open an Issue or a Pull Request anytime. You can also reach out on Discord if youd like to discuss ideas or improvements.
<a href="https://discord.gg/Bq4M2cDmF4">
<img src="https://img.shields.io/badge/Discord-Join%20Server-7289DA?logo=discord&logoColor=white" alt="Discord">
</a>
## 🧑‍💻 Developer Guide
@@ -251,7 +435,7 @@ To build or contribute to Bichon, the following environment is recommended:
```bash
git clone https://github.com/rustmailer/bichon.git
cd bichon
````
```
#### 2. Build the WebUI
@@ -279,9 +463,12 @@ cargo build
Or run directly:
```bash
export BICHON_ENCRYPT_PASSWORD=dummy-password-for-testing
cargo run -- --bichon-root-dir e:\bichon-data
```
`--bichon-root-dir` specifies the directory where **all Bichon data** will be stored.
`BICHON_ENCRYPT_PASSWORD` is the password used to encrypt the sensitive data (see `cargo run -- --help` for alternative ways to specify this).
### WebUI Access
@@ -298,5 +485,18 @@ This project is licensed under [AGPLv3](LICENSE).
- [Docker Hub](https://hub.docker.com/r/rustmailer/bichon)
- [Issue Tracker](https://github.com/rustmailer/bichon/issues)
- [Discord](https://discord.gg/evFnSpdpaE)
- [Discord](https://discord.gg/Bq4M2cDmF4)
## 💖 Support & Promotion
Bichon is an open-source email platform focused on privacy, local ownership, and long-term stability.
The project is freely available and fully functional for everyone.
Some members of the community choose to support the project financially. This support helps sustain ongoing development and long-term maintenance, while keeping the project independent and user-driven.
Support is always optional. You can also contribute by sharing feedback, reporting issues, or recommending Bichon to others.
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Support%20the%20Project-FFDD00?logo=buy-me-a-coffee)](https://buymeacoffee.com/rustmailer)

2
config.toml Normal file
View File

@@ -0,0 +1,2 @@
base_url = "http://localhost:15630"
api_token = "lZHmfpH1CRr9XsRiOGd1RnOr"

View File

@@ -17,6 +17,10 @@ COPY ${TARGETARCH}/LICENSE /opt/bichon/
# Set proper permissions
RUN chmod +x /opt/bichon/bichon
# Copy and setup entrypoint script for PUID/PGID support
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Install ca-certificates to ensure HTTPS certificate verification works correctly
RUN apt update && apt install -y ca-certificates curl && rm -rf /var/lib/apt/lists/*
# Create data directory
@@ -31,5 +35,6 @@ WORKDIR /data
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -fs http://localhost:15630/api/status || exit 1
# Entrypoint remains the binary
# Entrypoint with PUID/PGID support
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["/opt/bichon/bichon"]

41
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,41 @@
#!/bin/bash
# Entrypoint script for Bichon Docker container
# Handles PUID/PGID environment variables for proper user permissions
set -e
# Function to create user and switch to it
switch_user() {
local puid="$1"
local pgid="$2"
# Create group if it doesn't exist
if ! getent group bichon >/dev/null 2>&1; then
groupadd -g "$pgid" bichon
fi
# Create user if it doesn't exist
if ! getent passwd bichon >/dev/null 2>&1; then
useradd -u "$puid" -g "$pgid" -s /bin/bash -d /data bichon
fi
# Change ownership of directories that the process needs to write to
chown -R "$puid:$pgid" /data
chown -R "$puid:$pgid" /opt/bichon
# Handle mounted storage directories if they exist
[ -d /envelope ] && chown -R "$puid:$pgid" /envelope
[ -d /eml ] && chown -R "$puid:$pgid" /eml
# Switch to the user and execute the command
exec runuser -u bichon -- "$@"
}
# Check if PUID and PGID are set
if [ -n "$PUID" ] && [ -n "$PGID" ]; then
echo "Switching to user with PUID=$PUID, PGID=$PGID"
switch_user "$PUID" "$PGID" "$@"
else
echo "No PUID/PGID specified, running as root"
exec "$@"
fi

View File

@@ -28,7 +28,7 @@ BICHON_ROOT_DIR=/data/bichon-data
# Enable API access token validation
BICHON_ENABLE_ACCESS_TOKEN=false
# IP address to bind the HTTP and gRPC servers to (default: 0.0.0.0)
# IP address to bind the HTTP servers to (default: 0.0.0.0)
BICHON_BIND_IP=
# Comma-separated list of allowed CORS origins (e.g. https://app.example.com)

301
src/bin/bichon_admin.rs Normal file
View File

@@ -0,0 +1,301 @@
use std::{
fs,
path::{Path, PathBuf},
rc::Rc,
};
use bichon::modules::{
cli::admin::meta::{find_admin, init_meta_database, update_admin_password},
error::BichonError,
utils::encrypt::internal_decrypt_string,
};
use console::{style, Emoji};
use dialoguer::Confirm;
use dialoguer::{theme::ColorfulTheme, Input, Password, Select};
use native_db::Database;
#[tokio::main]
async fn main() {
let theme = ColorfulTheme::default();
println!(
"\n{}\n",
style("BICHON ADMINISTRATIVE TOOL").bold().bright().cyan()
);
let main_options = vec!["Reset Admin Password", "Exit"];
let selection = Select::with_theme(&theme)
.with_prompt("Select an operation")
.default(0)
.items(&main_options)
.interact()
.unwrap();
if selection == 1 {
println!("{}", style("Exiting...").dim());
return;
}
let root_dir_str: String = Input::with_theme(&theme)
.with_prompt("Enter the absolute path for 'bichon_root_dir'")
.validate_with(|input: &String| -> Result<(), &str> {
let path = Path::new(input);
if !path.is_absolute() {
return Err("Path must be absolute.");
}
if !path.exists() {
return Err("Directory does not exist.");
}
let has_metadata = path.join("meta.db").exists();
if !has_metadata {
return Err("Invalid directory: 'meta.db' not found.");
}
Ok(())
})
.interact_text()
.unwrap();
let root_path = PathBuf::from(&root_dir_str);
let database: Rc<Database<'static>> = match init_meta_database(&root_path.join("meta.db")) {
Ok(database) => database,
Err(e) => match e {
BichonError::Generic {
message,
location,
code,
} => {
if message.contains("RedbDatabaseError(DatabaseAlreadyOpen") {
println!("\n{}", style("ERROR: Database is locked.").red().bold());
println!(
"{}",
style("The Bichon service is likely still running.").yellow()
);
println!(
"Since the database cannot be shared between multiple instances, \n\
you must {} the Bichon service before proceeding.",
style("STOP").underlined().bold()
);
std::process::exit(1);
} else {
eprintln!(
"\n{} (Code: {:#?})\nLocation: {}\nMessage: {}",
style("A database error occurred:").red().bold(),
code,
location,
message
);
std::process::exit(1);
}
}
},
};
let admin = find_admin(&database);
match admin {
Ok(Some(user)) => {
println!("\n{}", style("Admin user found:").green().bold());
println!("----------------------------------------");
println!("{:<12} : {}", "Username", style(&user.username).cyan());
println!("{:<12} : {}", "Email", style(&user.email).cyan());
}
Ok(None) => {
println!(
"\n{}",
style("ERROR: No admin user found in the database.")
.red()
.bold()
);
println!("Please ensure the system has been initialized correctly.");
std::process::exit(1);
}
Err(e) => {
eprintln!(
"\n{} Failed to query admin user.",
style("ERROR:").red().bold()
);
eprintln!("Details: {:?}", e);
std::process::exit(1);
}
}
let encryption_key = loop {
let auth_methods = vec![
"Enter encryption password manually",
"Read from password file",
];
let method = Select::with_theme(&theme)
.with_prompt("How would you like to provide the database encryption key?")
.items(&auth_methods)
.interact()
.unwrap();
let raw_key = if method == 0 {
Password::with_theme(&theme)
.with_prompt("Enter Encryption Password")
.interact()
.unwrap()
} else {
let file_path: String = Input::with_theme(&theme)
.with_prompt("Enter path to encryption password file")
.interact_text()
.unwrap();
match fs::read_to_string(&file_path) {
Ok(content) => content.trim().to_string(),
Err(e) => {
println!("{}: {}", style("Failed to read file").red(), e);
continue;
}
}
};
if raw_key.is_empty() {
println!("{}", style("Key cannot be empty.").red());
continue;
}
let prompt_message = format!(
"Encryption key loaded: [ {} ]\n\n \
{}: This key must match the database encryption key used by the server.\n \
It corresponds to these settings in your service:\n \
- Arguments: {} or {}\n \
- Envs: {} or {}\n\n \
Do you want to continue?",
style(&raw_key).cyan().bold(),
style("IMPORTANT").yellow().bold(),
style("--bichon_encrypt_password").italic(),
style("--bichon_encrypt_password_file").italic(),
style("BICHON_ENCRYPT_PASSWORD").green(),
style("BICHON_ENCRYPT_PASSWORD_FILE").green()
);
if Confirm::with_theme(&theme)
.with_prompt(prompt_message)
.default(true)
.interact()
.unwrap()
{
break raw_key;
}
};
let admin = find_admin(&database);
match admin {
Ok(Some(user)) => {
println!("----------------------------------------");
println!("{:<12} : {}", "Username", style(&user.username).cyan());
println!("{:<12} : {}", "Email", style(&user.email).cyan());
let pwd_display = match &user.password {
Some(p) => {
let password = match internal_decrypt_string(&encryption_key, p) {
Ok(p) => p,
Err(e) => {
println!("\n{}", style("ERROR: Decryption Failed").red().bold());
println!(
"{}",
style("The provided encryption key is incorrect or invalid for this database.").yellow()
);
println!(
"{} Please verify your {} or the {} you provided.",
style("").cyan(),
style("encryption password").bold(),
style("key file").bold()
);
eprintln!("\nTechnical details: {:?}", e);
std::process::exit(1);
}
};
style(password).yellow().to_string()
}
None => style("None (No password set)").dim().italic().to_string(),
};
println!("{:<12} : {}", "Password", pwd_display);
println!("----------------------------------------");
if !dialoguer::Confirm::with_theme(&theme)
.with_prompt(format!(
"Do you want to reset the password for '{}'?",
user.username
))
.interact()
.unwrap()
{
println!("Operation cancelled.");
return;
}
}
Ok(None) => {
println!(
"\n{}",
style("ERROR: No admin user found in the database.")
.red()
.bold()
);
println!("Please ensure the system has been initialized correctly.");
std::process::exit(1);
}
Err(e) => {
eprintln!(
"\n{} Failed to query admin user.",
style("ERROR:").red().bold()
);
eprintln!("Details: {:?}", e);
std::process::exit(1);
}
}
println!(
"\n{}",
style("TARGET: Reset password for user 'admin'")
.yellow()
.bold()
);
let new_login_password = Password::with_theme(&theme)
.with_prompt("Enter new Admin Login Password")
.with_confirmation("Repeat password to confirm", "Passwords do not match!")
.interact()
.unwrap();
if !Confirm::with_theme(&theme)
.with_prompt("Proceed with database update?")
.interact()
.unwrap()
{
return;
}
println!("\n{} {}", style("").yellow(), "Updating database...");
match update_admin_password(&database, new_login_password, &encryption_key) {
Ok(_) => {
println!(
"\n{} {}",
Emoji("", "*"),
style("Success! Admin password has been updated.")
.green()
.bold()
);
println!(
"{}",
style("You can now log in with the new password.").dim()
);
}
Err(e) => {
println!(
"\n{}",
style("ERROR: Failed to update database").red().bold()
);
eprintln!(
"{} Could not save the new password to the database.",
style("").cyan()
);
eprintln!("\nDetails: {:?}", e);
std::process::exit(1);
}
}
}

95
src/bin/bichonctl.rs Normal file
View File

@@ -0,0 +1,95 @@
use bichon::modules::cli::{
BichonCli, BichonCtlConfig, auth::verify_user_and_get_account, eml::handle_eml_directory_import, mbox::handle_mbox_single_file_import, pst::handle_pst_import, thunderbird::handle_thunderbird_import
};
use clap::Parser;
use console::style;
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select};
use std::fs;
#[tokio::main]
async fn main() {
let cli = BichonCli::parse();
let theme = ColorfulTheme::default();
let config_path = &cli.config;
let mut current_config: Option<BichonCtlConfig> = None;
if config_path.exists() {
if let Ok(content) = fs::read_to_string(config_path) {
if let Ok(config) = toml::from_str::<BichonCtlConfig>(&content) {
println!("{}", style("✔ Existing configuration found:").green());
println!(" Base URL: {}", style(&config.base_url).yellow());
println!(" API Token: {}", style(&config.api_token).yellow());
// Confirm with user
if Confirm::with_theme(&theme)
.with_prompt("Do you want to use this configuration?")
.default(true)
.interact()
.unwrap()
{
current_config = Some(config);
}
}
}
}
let final_config = match current_config {
Some(conf) => conf,
None => {
println!("\n{}", style("Please enter Bichon service details:").bold());
let url: String = Input::with_theme(&theme)
.with_prompt("Bichon Base URL")
.default("http://localhost:15630".into())
.interact_text()
.unwrap();
let token: String = Input::with_theme(&theme)
.with_prompt("API Token")
.interact_text()
.unwrap();
let conf = BichonCtlConfig {
base_url: url,
api_token: token,
};
// 3. Offer to save the new configuration
if Confirm::with_theme(&theme)
.with_prompt("Save this configuration for future use?")
.default(true)
.interact()
.unwrap()
{
let toml_str = toml::to_string(&conf).unwrap();
fs::write(config_path, toml_str).expect("Failed to save config file");
println!("{}", style("Configuration saved successfully!").green());
}
conf
}
};
let target_account_id = verify_user_and_get_account(&final_config, &theme).await;
let import_modes = &[
"EML: Scan directory recursively (Maintains folder structure)",
"MBOX: Single archive file (Stream from one file)",
"Thunderbird: Import from local profile directory",
"PST: Outlook Personal Storage (Single .pst file)",
];
let mode_idx = Select::with_theme(&theme)
.with_prompt("Select import method")
.items(import_modes)
.default(0)
.interact()
.unwrap();
match mode_idx {
0 => handle_eml_directory_import(&final_config, target_account_id, &theme).await,
1 => handle_mbox_single_file_import(&final_config, target_account_id, &theme).await,
2 => handle_thunderbird_import(&final_config, target_account_id, &theme).await,
3 => handle_pst_import(&final_config, target_account_id, &theme).await,
_ => unreachable!(),
}
}

1
src/lib.rs Normal file
View File

@@ -0,0 +1 @@
pub mod modules;

View File

@@ -16,22 +16,23 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use mimalloc::MiMalloc;
use modules::{
common::rustls::RustMailerTls,
context::{executors::EmailClientExecutors, Initialize},
error::BichonResult,
logger,
rest::start_http_server,
tasks::PeriodicTasks,
token::root::ensure_root_token,
use bichon::{
bichon_version,
modules::{
common::rustls::RustMailerTls,
context::{executors::EmailClientExecutors, Initialize},
error::BichonResult,
logger,
rest::start_http_server,
tasks::PeriodicTasks,
},
};
use mimalloc::MiMalloc;
use tracing::info;
use crate::modules::{common::signal::SignalManager, settings::dir::DataDirManager};
mod modules;
use bichon::modules::{
common::signal::SignalManager, settings::dir::DataDirManager, users::manager::UserManager,
};
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
@@ -68,7 +69,7 @@ async fn initialize() -> BichonResult<()> {
// SETTINGS.validate()?;
SignalManager::initialize().await?;
DataDirManager::initialize().await?;
ensure_root_token().await?;
UserManager::initialize().await?;
RustMailerTls::initialize().await?;
EmailClientExecutors::initialize().await?;
PeriodicTasks::start_background_tasks();

View File

@@ -70,7 +70,6 @@ pub struct AuthConfig {
///
/// Users should provide a plaintext password (1 to 256 characters).
/// The server will encrypt the password using AES-256-GCM and securely store it.
/// The plaintext password is never stored, so users must remember it for authentication.
#[oai(validator(max_length = 256, min_length = 1))]
pub password: Option<String>,
}

View File

@@ -0,0 +1,160 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::{
modules::{
account::migration::AccountModel,
common::auth::ClientContext,
database::{manager::DB_MANAGER, with_transaction},
error::{code::ErrorCode, BichonResult},
users::{
permissions::Permission,
role::{RoleType, UserRole},
UserModel,
},
},
raise_error, utc_now,
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct BatchAccountRoleRequest {
pub account_ids: Vec<u64>,
pub user_ids: Vec<u64>,
pub role_id: u64,
}
impl BatchAccountRoleRequest {
pub async fn validate_existence(&self) -> BichonResult<()> {
let role = UserRole::find(self.role_id).await?.ok_or_else(|| {
raise_error!(
format!("Role ID {} not found", self.role_id),
ErrorCode::ResourceNotFound
)
})?;
if !matches!(role.role_type, RoleType::Account) {
return Err(raise_error!(
"Only Account roles can be assigned to individual account".into(),
ErrorCode::InvalidParameter
));
}
for id in &self.account_ids {
let exists = AccountModel::find(*id).await?; // Assuming an exists helper
if exists.is_none() {
return Err(raise_error!(
format!("Account ID {} not found", id),
ErrorCode::ResourceNotFound
));
}
}
for id in &self.user_ids {
let exists = UserModel::find(*id).await?; // Assuming an exists helper
if exists.is_none() {
return Err(raise_error!(
format!("User ID {} not found", id),
ErrorCode::ResourceNotFound
));
}
}
Ok(())
}
async fn grant_batch_account_access(
account_ids: Vec<u64>,
user_ids: Vec<u64>,
role_id: u64,
) -> BichonResult<()> {
with_transaction(DB_MANAGER.meta_db(), move |rw| {
for &uid in &user_ids {
// Fetch the current user record from the database
let user = rw
.get()
.primary::<UserModel>(uid)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User with id={} not found.", uid),
ErrorCode::ResourceNotFound
)
})?;
let mut updated_user = user.clone();
// Apply the role to each specified account_id
for &aid in &account_ids {
updated_user.account_access_map.insert(aid, role_id);
}
updated_user.updated_at = utc_now!();
// Save the updated user back to the database within the transaction
rw.update(user, updated_user)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
Ok(())
})
.await
}
pub async fn do_assign(self, context: &ClientContext) -> BichonResult<()> {
for account_id in &self.account_ids {
// Get the user's specific access for this account
let assigned_role_id =
context
.user
.account_access_map
.get(account_id)
.ok_or_else(|| {
raise_error!(
format!("No access to account {}", account_id),
ErrorCode::Forbidden
)
})?;
// Fetch the role definition from the database
let user_scoped_role = UserRole::find(*assigned_role_id).await?.ok_or_else(|| {
raise_error!(
"Assigned account role no longer exists".into(),
ErrorCode::InternalError
)
})?;
// Critical Check: Does this role grant management/sharing rights?
if !user_scoped_role
.permissions
.contains(Permission::ACCOUNT_MANAGE)
{
return Err(raise_error!(
format!("Your role on account {} does not allow sharing", account_id),
ErrorCode::Forbidden
));
}
// Optional: Ensure manager isn't giving away perms they don't have
// This is where you'd compare target_role.permissions vs manager's perms
}
Self::grant_batch_account_access(self.account_ids, self.user_ids, self.role_id).await
}
}

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use native_db::*;
use native_model::{native_model, Model};
@@ -28,11 +27,16 @@ use tracing::info;
use crate::{
encrypt,
modules::{
account::{entity::ImapConfig, since::DateSince, state::AccountRunningState},
account::{
entity::ImapConfig,
since::{DateSince, RelativeDate},
state::AccountRunningState,
},
cache::imap::mailbox::MailBox,
database::{insert_impl, list_all_impl},
database::{list_all_impl, with_transaction},
error::BichonResult,
indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
users::{role::DEFAULT_ACCOUNT_MANAGER_ROLE_ID, UserModel, DEFAULT_ADMIN_USER_ID},
},
utc_now,
};
@@ -53,10 +57,9 @@ use crate::modules::database::{
use crate::modules::error::code::ErrorCode;
use crate::modules::oauth2::token::OAuth2AccessToken;
use crate::modules::rest::response::DataPage;
use crate::modules::token::AccessToken;
use crate::raise_error;
pub type AccountModel = AccountV1;
pub type AccountModel = AccountV3;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)]
pub enum AccountType {
@@ -87,13 +90,77 @@ pub struct AccountV1 {
pub updated_at: i64,
pub use_proxy: Option<u64>,
}
impl AccountV1 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
}
pub fn new(request: AccountCreateRequest) -> BichonResult<Self> {
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
#[native_model(id = 4, version = 2, from = AccountV1)]
#[native_db(primary_key(pk -> String))]
pub struct AccountV2 {
#[secondary_key(unique)]
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
pub email: String,
pub name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub known_folders: Option<BTreeSet<String>>,
pub created_at: i64,
pub updated_at: i64,
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
}
impl AccountV2 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
#[native_model(id = 4, version = 3, from = AccountV2)]
#[native_db(primary_key(pk -> String))]
pub struct AccountV3 {
#[secondary_key(unique)]
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
pub email: String,
pub name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub sync_batch_size: Option<u32>,
pub known_folders: Option<BTreeSet<String>>,
pub created_at: i64,
pub updated_at: i64,
pub created_by: u64, //user id
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
}
impl AccountV3 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
pub fn new(user_id: u64, request: AccountCreateRequest) -> BichonResult<Self> {
Ok(Self {
id: id!(64),
email: request.email,
@@ -110,12 +177,17 @@ impl AccountV1 {
updated_at: utc_now!(),
use_proxy: request.use_proxy,
folder_limit: request.folder_limit,
use_dangerous: request.use_dangerous,
pgp_key: request.pgp_key,
created_by: user_id,
sync_batch_size: request.sync_batch_size,
date_before: request.date_before,
})
}
pub async fn check_account_active(account_id: u64) -> BichonResult<AccountModel> {
pub async fn check_account_exists(account_id: u64) -> BichonResult<AccountModel> {
let account =
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV1Key::id, account_id)
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::id, account_id)
.await?
.ok_or_else(|| {
raise_error!(
@@ -123,13 +195,6 @@ impl AccountV1 {
ErrorCode::ResourceNotFound
)
})?;
if !account.enabled {
return Err(raise_error!(
format!("Account id='{account_id}' is disabled"),
ErrorCode::AccountDisabled
));
}
Ok(account)
}
@@ -145,24 +210,48 @@ impl AccountV1 {
}
pub async fn find(account_id: u64) -> BichonResult<Option<AccountModel>> {
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV1Key::id, account_id)
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::id, account_id)
.await
}
/// Saves the current `AccountEntity` by persisting it to storage.
pub async fn save(&self) -> BichonResult<()> {
insert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
}
pub async fn create_account(
user_id: u64,
request: AccountCreateRequest,
) -> BichonResult<AccountModel> {
let entity = request.create_entity(user_id)?;
let cloned = entity.clone();
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let account_id = entity.id;
rw.insert::<AccountModel>(entity)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let user = rw
.get()
.primary::<UserModel>(user_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User with id={} not found.", user_id),
ErrorCode::ResourceNotFound
)
})?;
pub async fn create_account(request: AccountCreateRequest) -> BichonResult<AccountModel> {
let entity = request.create_entity()?;
entity.save().await?;
if matches!(entity.account_type, AccountType::IMAP) {
let mut updated = user.clone();
updated
.account_access_map
.insert(account_id, DEFAULT_ACCOUNT_MANAGER_ROLE_ID);
updated.updated_at = utc_now!();
rw.update(user, updated)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await?;
if matches!(cloned.account_type, AccountType::IMAP) {
SYNC_CONTROLLER
.trigger_start(entity.id, entity.email.clone())
.trigger_start(cloned.id, cloned.email.clone())
.await;
}
Ok(entity)
Ok(cloned)
}
pub async fn update(
@@ -199,7 +288,7 @@ impl AccountV1 {
async fn delete_account(account_id: u64) -> BichonResult<()> {
delete_impl(DB_MANAGER.meta_db(), move|rw|{
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(||raise_error!(format!("The account entity with id={account_id} that you want to delete was not found."), ErrorCode::ResourceNotFound))
}).await
}
@@ -211,7 +300,7 @@ impl AccountV1 {
MAIL_CONTEXT.clean_account(account.id).await?;
}
OAuth2AccessToken::try_delete(account.id).await?;
AccessToken::cleanup_account(account.id).await?;
UserModel::cleanup_account(account.id).await?;
MailBox::clean(account.id).await?;
ENVELOPE_INDEX_MANAGER
.delete_account_envelopes(account.id)
@@ -229,7 +318,7 @@ impl AccountV1 {
sync_folders: Vec<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account sync_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{
let mut updated = current.clone();
@@ -244,7 +333,7 @@ impl AccountV1 {
known_folders: BTreeSet<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account known_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{
let mut updated = current.clone();
@@ -259,7 +348,7 @@ impl AccountV1 {
capabilities: Vec<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV3Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!(format!("When trying to update account capabilities, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
}, |current|{
let mut updated = current.clone();
@@ -274,11 +363,13 @@ impl AccountV1 {
list_all_impl(DB_MANAGER.meta_db()).await
}
pub async fn minimal_list() -> BichonResult<Vec<MinimalAccount>> {
pub async fn minimal_list(only_nosync: bool) -> BichonResult<Vec<MinimalAccount>> {
let result = list_all_impl(DB_MANAGER.meta_db())
.await?
.into_iter()
.filter(|a: &AccountModel| a.enabled)
.filter(|account: &AccountModel| {
!only_nosync || matches!(account.account_type, AccountType::NoSync)
})
.map(|account: AccountModel| MinimalAccount {
id: account.id,
email: account.email,
@@ -288,7 +379,7 @@ impl AccountV1 {
}
pub async fn count() -> BichonResult<usize> {
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV1Key::id)
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::id)
.await
}
@@ -311,24 +402,52 @@ impl AccountV1 {
if let Some(date_since) = request.date_since {
new.date_since = Some(date_since);
new.date_before = None;
}
if let Some(date_before) = request.date_before {
new.date_before = Some(date_before);
new.date_since = None;
}
if let Some(clear_date_range) = request.clear_date_range {
if clear_date_range {
new.date_since = None;
new.date_before = None;
}
}
if let Some(folder_limit) = request.folder_limit {
new.folder_limit = Some(folder_limit);
}
if let Some(clear_folder_limit) = request.clear_folder_limit {
if clear_folder_limit {
new.folder_limit = None;
}
}
if let Some(name) = &request.name {
new.name = Some(name.clone());
if name.trim().is_empty() {
new.name = None;
} else {
new.name = Some(name.clone());
}
}
if matches!(old.account_type, AccountType::IMAP) {
if let Some(imap) = &request.imap {
let mut new_imap = imap.clone();
if let Some(password) = &new_imap.auth.password {
let encrypted_password = encrypt!(password)?;
new_imap.auth.password = Some(encrypted_password);
if let Some(current_imap) = &mut new.imap {
current_imap.host = imap.host.clone();
current_imap.port = imap.port.clone();
current_imap.encryption = imap.encryption.clone();
current_imap.auth.auth_type = imap.auth.auth_type.clone();
if let Some(password) = &imap.auth.password {
let encrypted_password = encrypt!(password)?;
current_imap.auth.password = Some(encrypted_password);
}
current_imap.use_proxy = imap.use_proxy;
}
new.imap = Some(new_imap);
}
if let Some(folder_names) = request.sync_folders {
@@ -337,6 +456,11 @@ impl AccountV1 {
if let Some(sync_interval_min) = &request.sync_interval_min {
new.sync_interval_min = Some(*sync_interval_min);
}
if let Some(sync_batch_size) = &request.sync_batch_size {
new.sync_batch_size = Some(*sync_batch_size);
}
if let Some(use_proxy) = request.use_proxy {
new.use_proxy = Some(use_proxy);
}
@@ -351,7 +475,113 @@ impl AccountV1 {
if let Some(enabled) = request.enabled {
new.enabled = enabled;
}
if let Some(use_dangerous) = request.use_dangerous {
new.use_dangerous = use_dangerous;
}
if let Some(pgp_key) = request.pgp_key {
new.pgp_key = Some(pgp_key);
}
new.updated_at = utc_now!();
Ok(new)
}
}
impl From<AccountV1> for AccountV2 {
fn from(value: AccountV1) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
use_proxy: value.use_proxy,
use_dangerous: false,
pgp_key: None,
}
}
}
impl From<AccountV2> for AccountV1 {
fn from(value: AccountV2) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
use_proxy: value.use_proxy,
}
}
}
impl From<AccountV3> for AccountV2 {
fn from(value: AccountV3) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
use_proxy: value.use_proxy,
use_dangerous: value.use_dangerous,
pgp_key: value.pgp_key,
}
}
}
impl From<AccountV2> for AccountV3 {
fn from(value: AccountV2) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
created_by: DEFAULT_ADMIN_USER_ID,
use_proxy: value.use_proxy,
use_dangerous: value.use_dangerous,
pgp_key: value.pgp_key,
sync_batch_size: None,
date_before: None,
}
}
}

View File

@@ -16,10 +16,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod dispatcher;
pub mod entity;
pub mod grant;
pub mod migration;
pub mod payload;
pub mod since;
pub mod state;
pub mod migration;
pub mod view;

View File

@@ -16,15 +16,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::BTreeSet;
use crate::modules::account::entity::ImapConfig;
use crate::modules::account::migration::{AccountModel, AccountType};
use crate::modules::account::since::DateSince;
use crate::modules::account::since::{DateSince, RelativeDate};
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
use crate::modules::token::AccountInfo;
use crate::{raise_error, validate_email};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
@@ -37,19 +33,37 @@ pub struct AccountCreateRequest {
pub imap: Option<ImapConfig>,
pub enabled: bool,
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub account_type: AccountType,
#[oai(validator(minimum(value = "100")))]
pub folder_limit: Option<u32>,
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
#[oai(validator(minimum(value = "10")))]
pub sync_interval_min: Option<i64>,
#[oai(validator(minimum(value = "30"), maximum(value = "200")))]
pub sync_batch_size: Option<u32>,
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
}
impl AccountCreateRequest {
pub fn create_entity(self) -> BichonResult<AccountModel> {
pub fn create_entity(self, user_id: u64) -> BichonResult<AccountModel> {
if self.date_before.is_some() && self.date_since.is_some() {
return Err(raise_error!(
"date_before and date_since are mutually exclusive; specify only one time boundary"
.into(),
ErrorCode::InvalidParameter
));
}
if let Some(date_since) = self.date_since.as_ref() {
date_since.validate()?;
}
if let Some(date_before) = self.date_before.as_ref() {
date_before.validate_date()?;
}
match self.account_type {
AccountType::IMAP => {
match &self.imap {
@@ -70,7 +84,7 @@ impl AccountCreateRequest {
}
AccountType::NoSync => {}
}
Ok(AccountModel::new(self)?)
Ok(AccountModel::new(user_id, self)?)
}
fn validate_request(imap: &ImapConfig, email: &str) -> BichonResult<()> {
@@ -106,11 +120,14 @@ pub struct AccountUpdateRequest {
/// - First-time sync optimization for large accounts
/// - Reducing server load during resyncs
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub clear_date_range: Option<bool>,
/// Max emails to sync for this folder.
/// If not set, sync all emails.
/// otherwise sync up to `n` most recent emails (min 10).
#[oai(validator(minimum(value = "100")))]
pub folder_limit: Option<u32>,
pub clear_folder_limit: Option<bool>,
/// Configuration for selective folder (mailbox/label) synchronization
///
/// - For IMAP/SMTP accounts:
@@ -126,19 +143,54 @@ pub struct AccountUpdateRequest {
/// Modified folders will be automatically synced on the next update.
pub sync_folders: Option<Vec<String>>,
/// Incremental sync interval (seconds)
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
#[oai(validator(minimum(value = "10")))]
pub sync_interval_min: Option<i64>,
#[oai(validator(minimum(value = "30"), maximum(value = "200")))]
pub sync_batch_size: Option<u32>,
/// Optional proxy ID for establishing the connection to external APIs (e.g., Gmail, Outlook).
/// - If `None` or not provided, the client will connect directly to the API server.
/// - If `Some(proxy_id)`, the client will use the pre-configured proxy with the given ID for API requests.
pub use_proxy: Option<u64>,
pub use_dangerous: Option<bool>,
pub pgp_key: Option<String>,
}
impl AccountUpdateRequest {
pub fn validate_update_request(&self, account: &AccountModel) -> BichonResult<()> {
if self.date_before.is_some() && self.date_since.is_some() {
return Err(raise_error!(
"date_before and date_since are mutually exclusive; specify only one time boundary"
.into(),
ErrorCode::InvalidParameter
));
}
if self.clear_folder_limit == Some(true) && self.folder_limit.is_some() {
return Err(raise_error!(
"clear_folder_limit cannot be combined with folder_limit".into(),
ErrorCode::InvalidParameter
));
}
if self.clear_date_range == Some(true)
&& (self.date_since.is_some() || self.date_before.is_some())
{
return Err(raise_error!(
"clear_date_range cannot be combined with date_since or date_before".into(),
ErrorCode::InvalidParameter
));
}
if let Some(date_since) = self.date_since.as_ref() {
date_since.validate()?;
}
if let Some(date_before) = self.date_before.as_ref() {
date_before.validate_date()?;
}
if matches!(account.account_type, AccountType::IMAP) {
if let Some(mailboxes) = self.sync_folders.as_ref() {
if mailboxes.is_empty() {
@@ -162,11 +214,11 @@ pub struct MinimalAccount {
pub fn filter_accessible_accounts<'a>(
all_accounts: &'a [MinimalAccount],
allowed: &BTreeSet<AccountInfo>,
allowed: &Vec<u64>,
) -> Vec<MinimalAccount> {
all_accounts
.iter()
.filter(|acct| allowed.iter().any(|a| a.id == acct.id))
.filter(|acct| allowed.contains(&acct.id))
.cloned()
.collect()
}

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::error::{code::ErrorCode, BichonResult},
raise_error,

View File

@@ -69,7 +69,7 @@ impl AccountRunningState {
errors: vec![],
is_initial_sync_completed: false,
progress: None,
initial_sync_start_time: None,
initial_sync_start_time: Some(utc_now!()),
initial_sync_end_time: None,
initial_sync_failed_time: None,
};
@@ -125,14 +125,14 @@ impl AccountRunningState {
.await
}
pub async fn set_initial_sync_start(account_id: u64) -> BichonResult<()> {
Self::update_account_running_state(account_id, move |current| {
let mut updated = current.clone();
updated.initial_sync_start_time = Some(utc_now!());
Ok(updated)
})
.await
}
// pub async fn set_initial_sync_start(account_id: u64) -> BichonResult<()> {
// Self::update_account_running_state(account_id, move |current| {
// let mut updated = current.clone();
// updated.initial_sync_start_time = Some(utc_now!());
// Ok(updated)
// })
// .await
// }
pub async fn set_initial_sync_completed(account_id: u64) -> BichonResult<()> {
Self::update_account_running_state(account_id, move |current| {

View File

@@ -0,0 +1,91 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::{BTreeSet, HashMap};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::modules::{
account::{
entity::ImapConfig,
migration::{AccountModel, AccountType},
since::{DateSince, RelativeDate},
},
users::UserModel,
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct AccountResp {
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
pub email: String,
pub name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub sync_batch_size: Option<u32>,
pub known_folders: Option<BTreeSet<String>>,
pub created_at: i64,
pub updated_at: i64,
pub created_by: u64, //user id
pub created_user_name: String,
pub created_user_email: String,
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
}
impl AccountResp {
pub fn from_model(account: AccountModel, user_map: &HashMap<u64, UserModel>) -> AccountResp {
let user = user_map.get(&account.created_by);
AccountResp {
id: account.id,
imap: account.imap,
enabled: account.enabled,
email: account.email,
name: account.name,
capabilities: account.capabilities,
date_since: account.date_since,
date_before: account.date_before,
folder_limit: account.folder_limit,
sync_folders: account.sync_folders,
account_type: account.account_type,
sync_interval_min: account.sync_interval_min,
sync_batch_size: account.sync_batch_size,
known_folders: account.known_folders,
created_at: account.created_at,
updated_at: account.updated_at,
created_by: account.created_by,
created_user_name: user
.map(|u| u.username.clone())
.unwrap_or_else(|| "Unknown".to_string()),
created_user_email: user
.map(|u| u.email.clone())
.unwrap_or_else(|| "N/A".to_string()),
use_proxy: account.use_proxy,
use_dangerous: account.use_dangerous,
pgp_key: account.pgp_key,
}
}
}

View File

@@ -16,13 +16,12 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
decode_mailbox_name, encode_mailbox_name,
modules::{
database::{
batch_delete_impl, batch_insert_impl, batch_upsert_impl, filter_by_secondary_key_impl,
manager::DB_MANAGER,
async_find_impl, batch_delete_impl, batch_insert_impl, batch_upsert_impl, delete_impl,
filter_by_secondary_key_impl, manager::DB_MANAGER,
},
error::{code::ErrorCode, BichonResult},
},
@@ -90,25 +89,25 @@ impl MailBox {
// Ok(())
// }
// pub async fn get(id: u64) -> RustMailerResult<MailBox> {
// let result = async_find_impl::<MailBox>(DB_MANAGER.envelope_db(), id).await?;
// Ok(result.ok_or_else(|| {
// raise_error!(
// format!("mailbox {} not found", id),
// ErrorCode::InternalError
// )
// })?)
// }
pub async fn get(id: u64) -> BichonResult<MailBox> {
let result = async_find_impl::<MailBox>(DB_MANAGER.envelope_db(), id).await?;
Ok(result.ok_or_else(|| {
raise_error!(
format!("mailbox {} not found", id),
ErrorCode::InternalError
)
})?)
}
// pub async fn delete(id: u64) -> BichonResult<()> {
// delete_impl(DB_MANAGER.envelope_db(), move |rw| {
// rw.get()
// .primary::<MailBox>(id)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
// .ok_or_else(|| raise_error!("mailbox missing".into(), ErrorCode::InternalError))
// })
// .await
// }
pub async fn delete(id: u64) -> BichonResult<()> {
delete_impl(DB_MANAGER.envelope_db(), move |rw| {
rw.get()
.primary::<MailBox>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!("mailbox missing".into(), ErrorCode::InternalError))
})
.await
}
pub async fn list_all(account_id: u64) -> BichonResult<Vec<MailBox>> {
filter_by_secondary_key_impl(DB_MANAGER.envelope_db(), MailBoxKey::account_id, account_id)

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
account::{migration::AccountModel, state::AccountRunningState},
@@ -24,7 +23,7 @@ use crate::{
imap::{
find_intersecting_mailboxes, find_missing_mailboxes,
mailbox::MailBox,
sync::rebuild::{rebuild_mailbox_cache, rebuild_mailbox_cache_since_date},
sync::rebuild::{rebuild_mailbox_cache, rebuild_mailbox_cache_by_date},
},
SEMAPHORE,
},
@@ -37,17 +36,30 @@ use crate::{
use std::time::Instant;
use tracing::{debug, error, info, warn};
pub const BATCH_SIZE: u32 = 50;
pub const DEFAULT_BATCH_SIZE: u32 = 50;
pub async fn fetch_and_save_since_date(
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum FetchDirection {
Since,
Before,
}
pub async fn fetch_and_save_by_date(
account: &AccountModel,
date: &str,
mailbox: &MailBox,
direction: FetchDirection,
) -> BichonResult<usize> {
let account_id = account.id;
let executor = MAIL_CONTEXT.imap(account_id).await?;
let search_criteria = match direction {
FetchDirection::Since => format!("SINCE {date}"),
FetchDirection::Before => format!("BEFORE {date}"),
};
let uid_list = executor
.uid_search(&mailbox.encoded_name(), format!("SINCE {date}").as_str())
.uid_search(&mailbox.encoded_name(), &search_criteria)
.await?;
let len = uid_list.len();
@@ -63,13 +75,23 @@ pub async fn fetch_and_save_since_date(
if let Some(limit) = folder_limit {
let limit = limit.max(100) as usize;
if len > limit {
uid_vec = uid_vec.split_off(len - limit as usize);
uid_vec = match direction {
FetchDirection::Since => uid_vec.split_off(len - limit),
FetchDirection::Before => {
uid_vec.truncate(limit);
uid_vec
}
};
}
}
// let semaphore = Arc::new(Semaphore::new(5));
let uid_batches = generate_uid_sequence_hashset(uid_vec, BATCH_SIZE as usize, false);
let uid_batches = generate_uid_sequence_hashset(
uid_vec,
account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize,
false,
);
AccountRunningState::set_initial_current_syncing_folder(
account_id,
mailbox.name.clone(),
@@ -105,9 +127,11 @@ pub async fn fetch_and_save_full_mailbox(
_ => total,
};
let page_size = if let Some(limit) = folder_limit {
limit.max(100).min(BATCH_SIZE as u32)
limit
.max(100)
.min(account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE))
} else {
BATCH_SIZE as u32
account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE)
};
let total_batches = total_to_fetch.div_ceil(page_size);
@@ -251,17 +275,30 @@ pub async fn reconcile_mailboxes(
match &account.date_since {
Some(date_since) => {
rebuild_mailbox_cache_since_date(
rebuild_mailbox_cache_by_date(
account,
local_mailbox.id,
date_since,
&date_since.since_date()?,
remote_mailbox,
FetchDirection::Since,
)
.await?;
}
None => {
rebuild_mailbox_cache(account, local_mailbox, remote_mailbox).await?;
}
None => match &account.date_before {
Some(r) => {
rebuild_mailbox_cache_by_date(
account,
local_mailbox.id,
&r.calculate_date()?,
remote_mailbox,
FetchDirection::Before,
)
.await?;
}
None => {
rebuild_mailbox_cache(account, local_mailbox, remote_mailbox).await?
}
},
}
} else {
perform_incremental_sync(account, local_mailbox, remote_mailbox).await?;
@@ -305,14 +342,31 @@ pub async fn reconcile_mailboxes(
let _permit = permit;
match &account.date_since {
Some(date_since) => {
rebuild_mailbox_cache_since_date(
&account, mailbox.id, date_since, &mailbox,
rebuild_mailbox_cache_by_date(
&account,
mailbox.id,
&date_since.since_date()?,
&mailbox,
FetchDirection::Since,
)
.await
}
None => {
rebuild_mailbox_cache(&account, &mailbox, &mailbox).await
}
None => match &account.date_before {
Some(r) => {
rebuild_mailbox_cache_by_date(
&account,
mailbox.id,
&r.calculate_date()?,
&mailbox,
FetchDirection::Before,
)
.await
}
None => {
rebuild_mailbox_cache(&account, &mailbox, &mailbox)
.await
}
},
}
});
handles.push(handle);
@@ -348,8 +402,14 @@ async fn perform_incremental_sync(
match local_max_uid {
Some(max_uid) => {
let executor = MAIL_CONTEXT.imap(account.id).await?;
let before_date = account
.date_before
.as_ref()
.map(|r| r.calculate_date())
.transpose()?;
executor
.fetch_new_mail(account.id, local_mailbox, max_uid + 1)
.fetch_new_mail(account, local_mailbox, max_uid + 1, before_date.as_deref())
.await?;
}
None => {
@@ -359,10 +419,11 @@ async fn perform_incremental_sync(
match &account.date_since {
Some(date_since) => {
fetch_and_save_since_date(
fetch_and_save_by_date(
account,
date_since.since_date()?.as_str(),
remote_mailbox,
FetchDirection::Since,
)
.await?;
}

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
account::{
@@ -24,13 +23,13 @@ use crate::{
migration::{AccountModel, AccountType},
state::AccountRunningState,
},
cache::imap::mailbox::MailBox,
cache::imap::{mailbox::MailBox, sync::flow::FetchDirection},
error::BichonResult,
},
utc_now,
};
use flow::reconcile_mailboxes;
use rebuild::{rebuild_cache, rebuild_cache_since_date};
use rebuild::{rebuild_cache, rebuild_cache_by_date};
use std::time::Instant;
use sync_folders::get_sync_folders;
use sync_type::{determine_sync_type, SyncType};
@@ -46,18 +45,35 @@ pub async fn execute_imap_sync(account: &AccountModel) -> BichonResult<()> {
let start_time = Instant::now();
let account_id = account.id;
let sync_type = determine_sync_type(account).await?;
if matches!(sync_type, SyncType::SkipSync) {
return Ok(());
}
let remote_mailboxes = get_sync_folders(account).await?;
if matches!(sync_type, SyncType::InitialSync) {
AccountRunningState::set_initial_sync_start(account_id).await?;
AccountRunningState::add(account.id).await?;
// AccountRunningState::set_initial_sync_start(account_id).await?;
let result = match &account.date_since {
Some(date_since) => {
rebuild_cache_since_date(account, &remote_mailboxes, date_since).await
rebuild_cache_by_date(
account,
&remote_mailboxes,
&date_since.since_date()?,
FetchDirection::Since,
)
.await
}
None => rebuild_cache(account, &remote_mailboxes).await,
None => match &account.date_before {
Some(r) => {
rebuild_cache_by_date(
account,
&remote_mailboxes,
&r.calculate_date()?,
FetchDirection::Before,
)
.await
}
None => rebuild_cache(account, &remote_mailboxes).await,
},
};
match result {
Ok(_) => {

View File

@@ -16,14 +16,13 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
account::{migration::AccountModel, since::DateSince},
account::migration::AccountModel,
cache::{
imap::{
mailbox::MailBox,
sync::flow::{fetch_and_save_full_mailbox, fetch_and_save_since_date},
sync::flow::{fetch_and_save_by_date, fetch_and_save_full_mailbox, FetchDirection},
},
SEMAPHORE,
},
@@ -86,14 +85,14 @@ pub async fn rebuild_cache(
Ok(())
}
pub async fn rebuild_cache_since_date(
pub async fn rebuild_cache_by_date(
account: &AccountModel,
remote_mailboxes: &[MailBox],
date_since: &DateSince,
date: &str,
direction: FetchDirection,
) -> BichonResult<()> {
let start_time = Instant::now();
let mut total_inserted = 0;
let date = date_since.since_date()?;
MailBox::batch_insert(remote_mailboxes).await?;
let mut handles = Vec::new();
@@ -107,13 +106,14 @@ pub async fn rebuild_cache_since_date(
}
let account = account.clone();
let mailbox = mailbox.clone();
let date = date.clone();
let date = date.to_string();
let direction = direction.clone();
match SEMAPHORE.clone().acquire_owned().await {
Ok(permit) => {
let handle: tokio::task::JoinHandle<Result<usize, BichonError>> =
tokio::spawn(async move {
let _permit = permit; // Ensure permit is released when task finishes
fetch_and_save_since_date(&account, date.as_str(), &mailbox).await
fetch_and_save_by_date(&account, date.as_str(), &mailbox, direction).await
});
handles.push(handle);
}
@@ -132,10 +132,14 @@ pub async fn rebuild_cache_since_date(
}
}
let elapsed_time = start_time.elapsed().as_secs();
let direction_desc = match direction {
FetchDirection::Since => "starting from the specified date",
FetchDirection::Before => "ending before the specified date",
};
info!(
"Rebuild account cache completed: {} envelopes inserted. {} secs elapsed. \
Data fetched from server starting from the specified date: {}.",
total_inserted, elapsed_time, date
Data fetched from server {}: {}.",
total_inserted, elapsed_time, direction_desc, date
);
Ok(())
}
@@ -169,11 +173,12 @@ pub async fn rebuild_mailbox_cache(
Ok(())
}
pub async fn rebuild_mailbox_cache_since_date(
pub async fn rebuild_mailbox_cache_by_date(
account: &AccountModel,
local_mailbox_id: u64,
date_since: &DateSince,
date: &str,
remote: &MailBox,
direction: FetchDirection,
) -> BichonResult<()> {
ENVELOPE_INDEX_MANAGER
.delete_mailbox_envelopes(account.id, vec![local_mailbox_id])
@@ -190,8 +195,7 @@ pub async fn rebuild_mailbox_cache_since_date(
return Ok(()); // Skip if the mailbox has no emails
}
let count =
fetch_and_save_since_date(account, date_since.since_date()?.as_str(), remote).await?;
let count = fetch_and_save_by_date(account, date, remote, direction).await?;
info!(
"Account {}: Successfully rebuild mailbox cache, inserted {} envelopes for mailbox '{}'.",
account.id, count, &remote.name

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
account::{migration::AccountModel, state::AccountRunningState},
@@ -51,10 +50,7 @@ pub async fn determine_sync_type(account: &AccountModel) -> BichonResult<SyncTyp
SyncType::SkipSync
}
}
None => {
AccountRunningState::add(account.id).await?;
SyncType::InitialSync
}
None => SyncType::InitialSync,
})
}

View File

@@ -0,0 +1,111 @@
use std::{path::Path, rc::Rc};
use native_db::{Builder, Database};
use crate::{
modules::{
database::META_MODELS,
error::{code::ErrorCode, BichonResult},
token::{AccessTokenModel, AccessTokenModelKey, TokenType},
users::{UserModel, DEFAULT_ADMIN_USER_ID},
utils::encrypt::internal_encrypt_string,
},
raise_error,
};
use itertools::Itertools;
pub fn init_meta_database(path: impl AsRef<Path>) -> BichonResult<Rc<Database<'static>>> {
let database = Builder::new()
.set_cache_size(134217728)
.create(&META_MODELS, path)
.map_err(|e| {
raise_error!(
format!("Failed to open database: {:?}", e),
ErrorCode::InternalError
)
})?;
Ok(Rc::new(database))
}
pub fn find_admin(database: &Rc<Database<'static>>) -> BichonResult<Option<UserModel>> {
let r_transaction = database
.r_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let entity: Option<UserModel> = r_transaction
.get()
.primary(DEFAULT_ADMIN_USER_ID)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
return Ok(entity);
}
pub fn update_admin_password(
database: &Rc<Database<'static>>,
password: String,
encrypt_key: &str,
) -> BichonResult<()> {
let rw_transaction = database
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let entity: UserModel = rw_transaction
.get()
.primary(DEFAULT_ADMIN_USER_ID)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!("admin is not found".into(), ErrorCode::InternalError))?;
let mut updated = entity.clone();
updated.password = Some(
internal_encrypt_string(encrypt_key, &password)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?,
);
rw_transaction
.update(entity, updated)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw_transaction
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
return Ok(());
}
pub fn reset_webui_token(database: &Rc<Database<'static>>) -> BichonResult<()> {
let rw_transaction = database
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let tokens: Vec<AccessTokenModel> = rw_transaction
.scan()
.secondary(AccessTokenModelKey::user_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.start_with(DEFAULT_ADMIN_USER_ID)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let webui_token = tokens
.into_iter()
.find(|t| t.token_type == TokenType::WebUI);
let new_token = AccessTokenModel::new_webui_token(DEFAULT_ADMIN_USER_ID);
match webui_token {
Some(current) => {
rw_transaction
.remove(current)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw_transaction
.insert(new_token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
None => {
rw_transaction
.insert(new_token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
}
rw_transaction
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
}

View File

@@ -0,0 +1 @@
pub mod meta;

169
src/modules/cli/auth.rs Normal file
View File

@@ -0,0 +1,169 @@
use std::process;
use console::style;
use dialoguer::{theme::ColorfulTheme, Select};
use reqwest::Client;
use crate::modules::{
account::payload::MinimalAccount,
cli::BichonCtlConfig,
users::{permissions::Permission, view::UserView},
};
pub async fn verify_user_and_get_account(config: &BichonCtlConfig, theme: &ColorfulTheme) -> u64 {
let client = Client::new();
let url = format!("{}/api/v1/current-user", config.base_url);
let response = match client
.get(&url)
.header("Authorization", format!("Bearer {}", config.api_token))
.send()
.await
{
Ok(res) => res,
Err(e) => {
eprintln!(
"\n{} {}",
style("✘ Network Error:").red().bold(),
"Could not connect to Bichon service."
);
eprintln!("{} {}", style("Details:").dim(), e);
eprintln!(
"\n{} Please check if the Base URL is correct and the server is running.",
style("Tip:").cyan()
);
process::exit(1);
}
};
if !response.status().is_success() {
let status = response.status();
let error_body = response
.text()
.await
.unwrap_or_else(|_| "No error detail provided".to_string());
eprintln!(
"\n{} Server returned an error (Status: {})",
style("✘ API Error:").red().bold(),
style(status).yellow()
);
if status == 401 {
eprintln!(
"{} Your API Token seems to be invalid or expired.",
style("Context:").dim()
);
} else if status == 404 {
eprintln!(
"{} The endpoint was not found. Please check your Base URL.",
style("Context:").dim()
);
}
eprintln!("{} {}", style("Response:").dim(), error_body);
process::exit(1);
}
let user: UserView = response.json().await.expect("Failed to parse user data");
println!("Welcome, {}!", style(&user.username).cyan());
let account_list_url = format!(
"{}/api/v1/minimal-account-list?only_nosync=true",
config.base_url
);
let acc_response = client
.get(&account_list_url)
.header("Authorization", format!("Bearer {}", config.api_token))
.send()
.await
.expect("Failed to fetch account list");
if !acc_response.status().is_success() {
panic!(
"Failed to retrieve accounts. Status: {}",
acc_response.status()
);
}
let accounts: Vec<MinimalAccount> = acc_response
.json()
.await
.expect("Failed to parse minimal account list");
if accounts.is_empty() {
println!(
"\n{}",
style("Error: No 'nosync' accounts found.").red().bold()
);
println!(
"{}",
style("Mail import is only supported for 'nosync' type accounts.").dim()
);
println!(
"Please create a new {} account in the Bichon web interface first.",
style("Nosync").bold().yellow()
);
process::exit(1);
}
let required_permission = Permission::DATA_IMPORT_BATCH;
let mut selectable_accounts = Vec::new();
let mut options = Vec::new();
for acc in accounts {
let has_permission = if let Some(perms) = user.account_permissions.get(&acc.id) {
perms.iter().any(|p| p == required_permission)
} else {
user.global_permissions
.iter()
.any(|p| p == Permission::DATA_MANAGE_ALL || p == Permission::ROOT)
};
let status_prefix = if has_permission {
style(" [READY] ").green()
} else {
style(" [NO PERMISSION] ").red()
};
options.push(format!(
"{}{} - {}",
status_prefix,
style(&acc.email).bold(),
style(format!("ID: {}", acc.id)).dim()
));
selectable_accounts.push((acc, has_permission));
}
let selection = Select::with_theme(theme)
.with_prompt("Select the target account for import")
.items(&options)
.default(0)
.max_length(10)
.interact()
.unwrap();
let (selected_acc, can_import) = &selectable_accounts[selection];
if !*can_import {
eprintln!(
"\n{} You do not have '{}' permission for account {}.",
style("✘ Permission Denied:").red().bold(),
style(required_permission).yellow(),
style(&selected_acc.email).cyan()
);
eprintln!(
"{} Please contact your administrator to upgrade your role for this account.",
style("Tip:").dim()
);
process::exit(1);
}
println!(
"{} Targeting account: {}",
style("").green(),
style(&selected_acc.email).cyan().bold()
);
selected_acc.id
}

133
src/modules/cli/eml/mod.rs Normal file
View File

@@ -0,0 +1,133 @@
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
};
use console::style;
use dialoguer::{theme::ColorfulTheme, Input};
use mail_parser::MessageParser;
use reqwest::Client;
use crate::{
base64_encode_url_safe,
modules::cli::{sender::send_batch_request, BichonCtlConfig},
};
pub async fn handle_eml_directory_import(
config: &BichonCtlConfig,
account_id: u64,
theme: &ColorfulTheme,
) {
let root_str: String = Input::with_theme(theme)
.with_prompt("Enter the ROOT directory to scan for .eml files")
.validate_with(|input: &String| {
let p = std::path::Path::new(input);
if p.exists() && p.is_dir() {
Ok(())
} else {
Err("Directory not found.")
}
})
.interact_text()
.unwrap();
let root_path = std::path::PathBuf::from(root_str);
let mut tasks: HashMap<String, Vec<PathBuf>> = HashMap::new();
println!(
"{}",
style("🔍 Scanning recursively using std::fs...").dim()
);
if let Err(e) = scan_dir(&root_path, &root_path, &mut tasks) {
eprintln!("Error scanning directory: {}", e);
return;
}
if tasks.is_empty() {
println!("{}", style("No .eml files found.").yellow());
} else {
process_and_upload(config, account_id, tasks).await;
}
}
fn scan_dir(
root: &Path,
current: &Path,
tasks: &mut HashMap<String, Vec<PathBuf>>,
) -> std::io::Result<()> {
if current.is_dir() {
for entry in fs::read_dir(current)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
scan_dir(root, &path, tasks)?;
} else if path.is_file() {
if path.extension().and_then(|s| s.to_str()) == Some("eml") {
let rel_path = path.strip_prefix(root).unwrap_or(Path::new(""));
let mailbox_name = rel_path
.parent()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
let folder = if mailbox_name.is_empty() {
"Inbox".to_string()
} else {
mailbox_name
};
tasks.entry(folder).or_insert_with(|| Vec::new()).push(path);
}
}
}
}
Ok(())
}
async fn process_and_upload(
config: &BichonCtlConfig,
account_id: u64,
tasks: HashMap<String, Vec<PathBuf>>,
) {
let client = Client::new();
let batch_size = 50;
for (mailbox, files) in tasks {
println!("\n🚀 Processing mailbox: {}", style(&mailbox).cyan().bold());
let mut current_batch = Vec::new();
for file_path in files {
let body = match fs::read(&file_path) {
Ok(b) => b,
Err(e) => {
eprintln!(
" {} Failed to read file {:?}: {}",
style("").red(),
file_path,
e
);
continue;
}
};
if MessageParser::new().parse(&body).is_some() {
let b64_content = base64_encode_url_safe!(&body);
current_batch.push(b64_content);
if current_batch.len() >= batch_size {
let to_send = current_batch;
current_batch = Vec::with_capacity(batch_size);
send_batch_request(&client, config, account_id, &mailbox, to_send).await;
}
} else {
eprintln!(
" {} Invalid format, skipping: {:?}",
style("").yellow(),
file_path
);
}
}
if !current_batch.is_empty() {
send_batch_request(&client, config, account_id, &mailbox, current_batch).await;
}
}
}

View File

@@ -0,0 +1,43 @@
use std::collections::HashSet;
pub fn determine_folder(labels_raw: &str) -> String {
let mut status_blacklist = HashSet::new();
status_blacklist.insert("Opened");
status_blacklist.insert("Unread");
status_blacklist.insert("Archived");
let all_labels: Vec<&str> = labels_raw
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
if all_labels.is_empty() {
return "Unknown".to_string();
}
let filtered: Vec<&str> = all_labels
.iter()
.filter(|&&l| !status_blacklist.contains(l))
.cloned()
.collect();
match filtered.len() {
// Case A: If all labels were status labels, fallback to the first original label
0 => all_labels[0].to_string(),
// Case B: If only one label remains, that's our target destination
1 => filtered[0].to_string(),
// Case C: Multiple labels remain (e.g., ["Inbox", "medium"])
_ => {
// Prioritize custom business labels by excluding generic locations like "Inbox" or "Sent"
let business_label = filtered.iter().find(|&&l| l != "Inbox" && l != "Sent");
match business_label {
// Return the first non-generic label found
Some(label) => label.to_string(),
// If only generic labels remain (e.g., ["Sent", "Inbox"]), pick the first available
None => filtered[0].to_string(),
}
}
}
}

151
src/modules/cli/mbox/mod.rs Normal file
View File

@@ -0,0 +1,151 @@
use std::collections::HashMap;
use std::path::PathBuf;
use crate::base64_encode_url_safe;
use crate::modules::cli::mbox::gmail::determine_folder;
use crate::modules::cli::mbox::reader::MboxFile;
use crate::modules::cli::sender::send_batch_request;
use crate::modules::cli::BichonCtlConfig;
use console::style;
use dialoguer::{theme::ColorfulTheme, Input};
use dialoguer::{Confirm, Select};
use mail_parser::MessageParser;
use reqwest::Client;
pub mod gmail;
pub mod reader;
pub async fn handle_mbox_single_file_import(
config: &BichonCtlConfig,
account_id: u64,
theme: &ColorfulTheme,
) {
let path_str: String = Input::with_theme(theme)
.with_prompt("Enter the path to your SINGLE .mbox file")
.validate_with(|input: &String| {
let p = std::path::Path::new(input);
if !p.exists() {
return Err("The specified path does not exist.");
}
if !p.is_file() {
return Err("MBOX mode requires a SINGLE file, not a directory.");
}
Ok(())
})
.interact_text()
.unwrap();
let mbox_path = PathBuf::from(path_str);
let options = vec![
"Use labels from mail headers (X-Gmail-Labels)",
"Specify a single target folder for all emails",
];
let selection = Select::with_theme(theme)
.with_prompt("How should we determine the target folder?")
.items(&options)
.default(0)
.interact()
.unwrap();
let target_folder: Option<String> = match selection {
0 => None,
1 => {
let folder: String = Input::with_theme(theme)
.with_prompt("Target folder name")
.default("INBOX".into())
.interact_text()
.unwrap();
Some(folder)
}
_ => unreachable!(),
};
if let Some(ref folder) = target_folder {
println!(
"{}",
style(format!("Mode: Fixed folder ({})", folder)).dim()
);
} else {
println!("{}", style("Mode: Dynamic (header-based)").dim());
}
println!(
"\n{} Ready to process MBOX file: {}",
style("").green(),
style(mbox_path.display()).cyan()
);
if let Ok(meta) = std::fs::metadata(&mbox_path) {
let size_mb = meta.len() as f64 / 1024.0 / 1024.0;
println!(
"{}",
style(format!("Processing file: {:.1} MB", size_mb)).dim()
);
}
if Confirm::with_theme(theme)
.with_prompt("Start importing?")
.default(true)
.interact()
.unwrap()
{
run_import(account_id, &mbox_path, config, target_folder).await
}
}
pub async fn run_import(
account_id: u64,
mbox_path: &PathBuf,
config: &BichonCtlConfig,
target_folder: Option<String>,
) {
let client = Client::new();
let mbox = match MboxFile::from_file(mbox_path) {
Ok(mbox) => mbox,
Err(err) => {
println!("Skipping invalid MBOX: {} ({})", mbox_path.display(), err);
return;
}
};
let mut folder_buffers: HashMap<String, Vec<String>> = HashMap::new();
let batch_limit = 50;
println!("Starting import process...");
for e in mbox.iter() {
let body = e.data;
let message = MessageParser::new().parse(body).unwrap();
let folder_name = match target_folder {
Some(ref folder_name) => folder_name.clone(),
None => {
let labels = message
.header("X-Gmail-Labels")
.and_then(|h| h.as_text())
.unwrap_or("Inbox");
determine_folder(labels)
}
};
let b64_eml = base64_encode_url_safe!(&body);
let buffer = folder_buffers
.entry(folder_name.clone())
.or_insert_with(|| Vec::new());
buffer.push(b64_eml);
if buffer.len() >= batch_limit {
let emls_to_send = folder_buffers.remove(&folder_name).unwrap();
send_batch_request(&client, config, account_id, &folder_name, emls_to_send).await;
}
}
for (folder_name, emls) in folder_buffers {
if !emls.is_empty() {
send_batch_request(&client, config, account_id, &folder_name, emls).await;
}
}
println!("{}", style("Import completed successfully!").green().bold());
}

View File

@@ -0,0 +1,208 @@
use memmap2::Mmap;
use std::fs;
use std::io;
use std::path::Path;
pub struct MboxFile {
map: Mmap,
}
impl MboxFile {
pub fn from_file(name: &Path) -> io::Result<Self> {
let file = fs::File::open(name)?;
let metadata = file.metadata()?;
if metadata.len() == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Empty MBOX file",
));
}
let map = unsafe { Mmap::map(&file)? };
Ok(Self { map })
}
pub fn iter(&self) -> MboxReader<'_> {
MboxReader::new(&self.map)
}
}
pub struct Entry<'a> {
pub offset: usize,
pub data: &'a [u8],
}
pub struct MboxReader<'a> {
data: &'a [u8],
len: usize,
scan_pos: usize,
body_start: Option<usize>,
}
impl<'a> MboxReader<'a> {
fn new(data: &'a [u8]) -> Self {
Self {
data,
len: data.len(),
scan_pos: 0,
body_start: None,
}
}
fn is_from_line(&self, i: usize) -> bool {
if i + 5 > self.len {
return false;
}
if i == 0 {
&self.data[0..5] == b"From "
} else {
self.data[i - 1] == b'\n' && &self.data[i..i + 5] == b"From "
}
}
fn skip_from_line(&self, mut i: usize) -> usize {
while i < self.len && self.data[i] != b'\n' {
i += 1;
}
if i < self.len {
i += 1;
}
i
}
}
impl<'a> Iterator for MboxReader<'a> {
type Item = Entry<'a>;
fn next(&mut self) -> Option<Self::Item> {
while self.scan_pos < self.len {
if self.is_from_line(self.scan_pos) {
let from_pos = self.scan_pos;
let body_pos = self.skip_from_line(from_pos);
if let Some(start) = self.body_start {
let entry = Entry {
offset: start,
data: &self.data[start..from_pos],
};
self.body_start = Some(body_pos);
self.scan_pos = body_pos;
return Some(entry);
} else {
self.body_start = Some(body_pos);
self.scan_pos = body_pos;
continue;
}
}
self.scan_pos += 1;
}
if let Some(start) = self.body_start.take() {
return Some(Entry {
offset: start,
data: &self.data[start..self.len],
});
}
None
}
}
#[cfg(test)]
mod tests {
use mail_parser::MessageParser;
use crate::modules::cli::mbox::gmail::determine_folder;
use super::*;
fn collect_entries(data: &[u8]) -> Vec<&[u8]> {
let reader = MboxReader::new(data);
reader.map(|e| e.data).collect()
}
#[test]
fn two_mails() {
let data = b"From a\nmail1\nFrom b\nmail2\n";
let e = collect_entries(data);
assert_eq!(e, vec![b"mail1\n", b"mail2\n"]);
}
#[test]
fn no_trailing_newline() {
let data = b"From a\nmail1";
let e = collect_entries(data);
assert_eq!(e, vec![b"mail1"]);
}
#[test]
fn from_inside_body() {
let data = b"From a\nhello\nFrom is here\nbye\n";
let e = collect_entries(data);
assert_eq!(e.len(), 2);
}
#[test]
fn inline_from_not_separator() {
let data = b"From a\nhello From world\n";
let e = collect_entries(data);
assert_eq!(e.len(), 1);
}
#[test]
fn realistic_mbox() {
let data = b"From a\nH:1\n\nbody1\nFrom b\nH:2\n\nbody2\n";
let e = collect_entries(data);
assert_eq!(e.len(), 2);
}
#[test]
fn empty_body() {
let data = b"From a\nFrom b\nbody\n";
let e = collect_entries(data);
assert_eq!(e[0], b"");
assert_eq!(e[1], b"body\n");
}
#[test]
fn only_from_line() {
let data = b"From a\n";
let e = collect_entries(data);
assert_eq!(e.len(), 1);
assert_eq!(e[0], b"");
}
#[test]
fn windows_newlines() {
let data = b"From a\r\nbody\r\nFrom b\r\nbody2\r\n";
let e = collect_entries(data);
assert_eq!(e.len(), 2);
}
#[test]
fn many_small_mails() {
let mut data = Vec::new();
for _ in 0..1000 {
data.extend_from_slice(b"From a\nx\n");
}
let e = collect_entries(&data);
assert_eq!(e.len(), 1000);
}
#[test]
fn test11() {
let mbox = MboxFile::from_file(Path::new("e:\\test.mbox")).unwrap();
for e in mbox.iter() {
let body = e.data;
let message = MessageParser::new().parse(body).unwrap();
let labels = message.header("X-Gmail-Labels").unwrap().as_text().unwrap();
//println!("offset={} X-Gmail-Labels={:?}", e.offset, labels);
println!(
"X-Gmail-Labels={:?}, determine_folder={}",
labels,
determine_folder(labels)
)
}
}
}

37
src/modules/cli/mod.rs Normal file
View File

@@ -0,0 +1,37 @@
use clap::Parser;
use serde::{Deserialize, Serialize};
use crate::bichon_version;
pub mod admin;
pub mod auth;
pub mod eml;
pub mod mbox;
pub mod pst;
pub mod sender;
pub mod thunderbird;
#[derive(Parser, Debug)]
#[command(
name = "bichonctl",
author = "rustmailer",
version = bichon_version!(),
about = "A CLI tool to import email data into Bichon service"
)]
pub struct BichonCli {
/// Path to the configuration file
#[arg(
short,
long,
default_value = "config.toml",
value_name = "FILE",
help = "Sets a custom config file"
)]
pub config: std::path::PathBuf,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct BichonCtlConfig {
pub base_url: String,
pub api_token: String,
}

View File

@@ -0,0 +1,45 @@
use compressed_rtf::*;
use outlook_pst::ltp::prop_context::PropertyValue;
pub fn decode_subject(value: &PropertyValue) -> Option<String> {
match value {
PropertyValue::String8(value) => {
let offset = match value.buffer().first() {
Some(1) => 2,
_ => 0,
};
let buffer: Vec<_> = value
.buffer()
.iter()
.skip(offset)
.map(|&b| u16::from(b))
.collect();
Some(String::from_utf16_lossy(&buffer))
}
PropertyValue::Unicode(value) => {
let offset = match value.buffer().first() {
Some(1) => 2,
_ => 0,
};
Some(String::from_utf16_lossy(&value.buffer()[offset..]))
}
_ => None,
}
}
pub fn decode_html_body(buffer: &[u8], code_page: u16) -> Option<String> {
match code_page {
20127 => {
let buffer: Vec<_> = buffer.iter().map(|&b| u16::from(b)).collect();
Some(String::from_utf16_lossy(&buffer))
}
_ => {
let coding = codepage_strings::Coding::new(code_page).ok()?;
Some(coding.decode(buffer).ok()?.to_string())
}
}
}
pub fn decode_rtf_compressed(buffer: &[u8]) -> Option<String> {
decompress_rtf(buffer).ok()
}

458
src/modules/cli/pst/mod.rs Normal file
View File

@@ -0,0 +1,458 @@
use chrono::{DateTime, TimeZone, Utc};
use dialoguer::theme::ColorfulTheme;
use dialoguer::Input;
use mail_send::mail_builder::headers::text::Text;
use mail_send::mail_builder::MessageBuilder;
use outlook_pst::ltp::prop_context::PropertyValue;
use crate::base64_encode_url_safe;
use crate::modules::cli::pst::encoding::decode_subject;
use crate::modules::cli::sender::send_batch_request;
use crate::modules::cli::BichonCtlConfig;
use dialoguer::Confirm;
use outlook_pst::messaging::attachment::AttachmentProperties;
use outlook_pst::messaging::folder::Folder;
use outlook_pst::messaging::message::{Message, MessageProperties};
use outlook_pst::ndb::node_id::NodeId;
use reqwest::Client;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::rc::Rc;
mod encoding;
#[derive(Debug, Default)]
pub struct EmailMetadata {
pub message_id: Option<String>,
pub subject: Option<String>,
pub from: Option<String>,
pub to: Option<Vec<String>>,
pub cc: Option<Vec<String>>,
pub bcc: Option<Vec<String>>,
pub html: Option<String>,
pub text: Option<String>,
pub in_reply_to: Option<String>,
}
#[derive(Debug, Default)]
pub struct EmailAttachment {
pub name: Option<String>,
pub mime_type: Option<String>,
pub data: Option<Vec<u8>>,
}
pub async fn handle_pst_import(config: &BichonCtlConfig, account_id: u64, theme: &ColorfulTheme) {
let path_str: String = Input::with_theme(theme)
.with_prompt("Enter the path to your SINGLE .pst file")
.validate_with(|input: &String| {
let p = std::path::Path::new(input);
if !p.exists() {
return Err("The specified path does not exist.");
}
if !p.is_file() {
return Err("PST mode requires a SINGLE file, not a directory.");
}
let is_pst = p
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("pst"))
.unwrap_or(false);
if !is_pst {
return Err("The selected file must have a .pst extension.");
}
Ok(())
})
.interact_text()
.unwrap();
let pst_path = std::path::PathBuf::from(path_str);
println!(
"\n{} Ready to process PST file: {}",
console::style("").green(),
console::style(pst_path.display()).cyan()
);
if let Ok(meta) = std::fs::metadata(&pst_path) {
let size_mb = meta.len() as f64 / 1024.0 / 1024.0;
println!(
"{}",
console::style(format!("PST File Size: {:.1} MB", size_mb)).dim()
);
}
if Confirm::with_theme(theme)
.with_prompt("Start importing emails from this PST?")
.default(true)
.interact()
.unwrap()
{
parse_pst(pst_path, config, account_id).await;
} else {
println!("{}", console::style("Operation cancelled by user.").red());
}
}
async fn parse_pst(pst_path: PathBuf, config: &BichonCtlConfig, account_id: u64) {
let client = Client::new();
let pst_store = match outlook_pst::open_store(&pst_path) {
Ok(store) => store,
Err(e) => {
println!(
"{} Failed to open PST file: {}",
console::style("").red(),
console::style(format!("{:#?}", e)).dim()
);
return;
}
};
let ipm_sub_tree = match pst_store.properties().ipm_sub_tree_entry_id() {
Ok(id) => id,
Err(e) => {
println!(
"{} Could not find IPM_SUBTREE (Mailbox Root): {}",
console::style("").red(),
console::style(format!("{:#?}", e)).dim()
);
return;
}
};
let ipm_subtree_folder = match pst_store.open_folder(&ipm_sub_tree) {
Ok(folder) => folder,
Err(e) => {
println!(
"{} Failed to open the root mailbox folder: {}",
console::style("").red(),
console::style(format!("{:#?}", e)).dim()
);
return;
}
};
process_folder_recursively(&client, &ipm_subtree_folder, "", config, account_id).await;
}
fn process_folder_recursively<'a>(
client: &'a Client,
folder: &'a Rc<dyn Folder>,
parent_path: &'a str,
config: &'a BichonCtlConfig,
account_id: u64,
) -> Pin<Box<dyn Future<Output = ()> + 'a>> {
Box::pin(async move {
let folder_name = folder
.properties()
.display_name()
.unwrap_or_else(|_| "Unknown".to_string());
let current_path = if parent_path.is_empty() {
folder_name
} else {
format!("{}/{}", parent_path, folder_name)
};
println!(
"{} {}",
console::style("📁 Folder:").dim(),
console::style(&current_path).cyan()
);
let mut emls_batch = Vec::new();
if let Some(contents_table) = folder.contents_table() {
for row in contents_table.rows_matrix() {
let store = folder.store().clone();
let entry_id = match store
.properties()
.make_entry_id(NodeId::from(u32::from(row.id())))
{
Ok(id) => id,
Err(e) => {
eprintln!(
" {} Skip row {}: {:?}",
console::style("").yellow(),
row.unique(),
e
);
continue;
}
};
match store.open_message(&entry_id, None) {
Ok(message) => match build_eml_base64(message) {
Some(base64_eml) => emls_batch.push(base64_eml),
None => {}
},
Err(e) => eprintln!(" {} Open error: {:?}", console::style("").yellow(), e),
}
if emls_batch.len() >= 50 {
let batch = emls_batch.clone();
emls_batch.clear();
send_to_bichon(client, config, account_id, &current_path, batch).await;
}
}
}
if !emls_batch.is_empty() {
send_to_bichon(client, config, account_id, &current_path, emls_batch).await;
}
if let Some(hierarchy_table) = folder.hierarchy_table() {
for row in hierarchy_table.rows_matrix() {
let node = NodeId::from(u32::from(row.id()));
if let Ok(entry_id) = folder.store().properties().make_entry_id(node) {
if let Ok(sub_folder) = folder.store().open_folder(&entry_id) {
process_folder_recursively(
client,
&sub_folder,
&current_path,
config,
account_id,
)
.await;
}
}
}
}
})
}
fn build_eml_base64(message: Rc<dyn Message>) -> Option<String> {
let properties = message.properties();
let mut builder = MessageBuilder::new();
if let Some(sub) = extract_subject(properties) {
builder = builder.subject(sub);
}
if let Some(mid) = extract_string_property(properties, 0x1035) {
builder = builder.message_id(mid);
}
if let Some(irt) = extract_string_property(properties, 0x1042) {
builder = builder.in_reply_to(irt);
}
if let Some(refs) = extract_string_property(properties, 0x1039) {
builder = builder.header("References", Text::new(refs));
}
if let Some(cid_val) = properties.get(0x3013) {
if let PropertyValue::Binary(bin) = cid_val {
builder = builder.header(
"X-Bichon-Conversation-ID",
Text::new(hex::encode(bin.buffer())),
);
}
}
let from = extract_string_property(properties, 0x5D01)
.or_else(|| extract_string_property(properties, 0x5D02))
.or_else(|| extract_string_property(properties, 0x0C1F));
if let Some(f) = from {
builder = builder.from(f);
}
if let Some(filetime) = extract_i64_property(properties, &[0x0039, 0x0E06]) {
let dt = filetime_to_datetime(filetime).timestamp();
builder = builder.date(dt);
}
let (to, cc, bcc) = extract_recipients_list(&message);
if !to.is_empty() {
builder = builder.to(to.iter().map(|s| s.as_str()).collect::<Vec<_>>());
}
if !cc.is_empty() {
builder = builder.cc(cc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
}
if !bcc.is_empty() {
builder = builder.bcc(bcc.iter().map(|s| s.as_str()).collect::<Vec<_>>());
}
if let Some(html) = extract_html(properties) {
builder = builder.html_body(html);
}
if let Some(text) = extract_text(properties) {
builder = builder.text_body(text);
}
if let Some(attachment_table) = message.attachment_table() {
for row in attachment_table.rows_matrix() {
let node_id = NodeId::from(u32::from(row.id()));
if let Ok(attachment) = message.clone().read_attachment(node_id, None) {
let att_props = attachment.properties();
let name = extract_attachment_string_property(att_props, 0x3707);
let mime = extract_attachment_string_property(att_props, 0x370E)
.unwrap_or_else(|| "application/octet-stream".into());
let cid = extract_attachment_string_property(att_props, 0x3712);
let is_inline = att_props
.get(0x3714)
.and_then(|val| {
if let PropertyValue::Integer32(f) = val {
Some(f)
} else {
None
}
})
.map(|flag| (flag & 0x4) != 0)
.unwrap_or(false);
if let Some(PropertyValue::Binary(bin)) = att_props.get(0x3701) {
let data = bin.buffer().to_vec();
let file_name = name.unwrap_or_else(|| "unnamed_attachment".to_string());
if is_inline && cid.is_some() {
let content_id = cid.unwrap();
builder = builder.inline(mime, content_id, data);
} else {
builder = builder.attachment(mime, file_name, data);
}
}
}
}
}
match builder.write_to_vec() {
Ok(eml_vec) => Some(base64_encode_url_safe!(eml_vec)),
Err(e) => {
eprintln!("Failed to generate EML: {:?}", e);
None
}
}
}
fn filetime_to_datetime(filetime: i64) -> DateTime<Utc> {
let unix_secs = (filetime / 10_000_000) - 11_644_473_600;
let nsecs = (filetime % 10_000_000) * 100;
Utc.timestamp_opt(unix_secs, nsecs as u32).unwrap()
}
fn extract_recipients_list(message: &Rc<dyn Message>) -> (Vec<String>, Vec<String>, Vec<String>) {
let mut to = Vec::new();
let mut cc = Vec::new();
let mut bcc = Vec::new();
let recipient_table = message.recipient_table();
let context = recipient_table.context();
for row in recipient_table.rows_matrix() {
if let Ok(cols) = row.columns(context) {
let mut r_type = 0;
let mut email = String::new();
for (col, val) in context.columns().iter().zip(cols) {
let prop_val = val
.as_ref()
.and_then(|v| recipient_table.read_column(v, col.prop_type()).ok());
match col.prop_id() {
0x0C15 => {
if let Some(PropertyValue::Integer32(t)) = prop_val {
r_type = t;
}
}
0x39FE | 0x3003 => {
if let Some(s) = prop_val.and_then(|v| extract_string(&v)) {
email = s;
}
}
_ => {}
}
}
if !email.is_empty() {
match r_type {
1 => to.push(email),
2 => cc.push(email),
3 => bcc.push(email),
_ => {}
}
}
}
}
(to, cc, bcc)
}
async fn send_to_bichon(
client: &Client,
config: &BichonCtlConfig,
account_id: u64,
folder_path: &str,
emls: Vec<String>,
) {
send_batch_request(client, config, account_id, folder_path, emls).await;
}
fn extract_subject(props: &MessageProperties) -> Option<String> {
props.get(0x0037).and_then(|val| decode_subject(val))
}
fn extract_string_property(properties: &MessageProperties, prop_id: u16) -> Option<String> {
properties
.get(prop_id)
.and_then(|value| extract_string(value))
}
fn extract_attachment_string_property(
properties: &AttachmentProperties,
prop_id: u16,
) -> Option<String> {
properties
.get(prop_id)
.and_then(|value| extract_string(value))
}
fn extract_string(value: &PropertyValue) -> Option<String> {
match value {
PropertyValue::String8(value) => Some(value.to_string()),
PropertyValue::Unicode(value) => Some(value.to_string()),
_ => None,
}
}
fn extract_text(properties: &MessageProperties) -> Option<String> {
properties.get(0x1000).and_then(extract_string).or_else(|| {
properties.get(0x1009).and_then(|value| match value {
PropertyValue::Binary(value) => encoding::decode_rtf_compressed(value.buffer()),
_ => None,
})
})
}
fn extract_html(properties: &MessageProperties) -> Option<String> {
properties.get(0x1013).and_then(|value| match value {
PropertyValue::Binary(value) => {
let code_page = properties
.get(0x3FDE)
.and_then(|v| {
if let PropertyValue::Integer32(cpid) = v {
Some(*cpid as u16)
} else {
None
}
})
.unwrap_or(65001);
encoding::decode_html_body(value.buffer(), code_page)
}
PropertyValue::String8(value) => Some(value.to_string()),
PropertyValue::Unicode(value) => Some(value.to_string()),
_ => None,
})
}
fn extract_i64_property(properties: &MessageProperties, prop_ids: &[u16]) -> Option<i64> {
for &prop_id in prop_ids {
if let Some(PropertyValue::Time(value)) = properties.get(prop_id) {
return Some(*value);
}
}
None
}

54
src/modules/cli/sender.rs Normal file
View File

@@ -0,0 +1,54 @@
use console::style;
use reqwest::Client;
use crate::modules::{cli::BichonCtlConfig, import::BatchEmlRequest};
pub async fn send_batch_request(
client: &Client,
config: &BichonCtlConfig,
account_id: u64,
folder: &str,
emls: Vec<String>,
) {
let url = format!("{}/api/v1/import", config.base_url);
let payload = BatchEmlRequest {
account_id,
mail_folder: folder.to_string(),
emls,
};
let count = payload.emls.len();
match client
.post(&url)
.header("Authorization", format!("Bearer {}", config.api_token))
.json(&payload)
.send()
.await
{
Ok(res) if res.status().is_success() => {
println!(
" {} Sent {} emails to [{}]",
style("").green(),
count,
folder
);
}
Ok(res) => {
eprintln!(
" {} Failed to send to [{}]. Status: {}",
style("").red(),
folder,
res.status()
);
}
Err(e) => {
eprintln!(
" {} Network error on [{}]: {}",
style("").red(),
folder,
e
);
}
}
}

View File

@@ -0,0 +1,113 @@
use std::{collections::HashMap, path::PathBuf};
use crate::modules::cli::{mbox::run_import, BichonCtlConfig};
use console::style;
use dialoguer::{theme::ColorfulTheme, Confirm, Input};
pub async fn handle_thunderbird_import(
config: &BichonCtlConfig,
account_id: u64,
theme: &ColorfulTheme,
) {
let root_str: String = Input::with_theme(theme)
.with_prompt("Enter your Thunderbird Mail/ImapMail directory")
.validate_with(|input: &String| {
let p = std::path::Path::new(input);
if p.exists() && p.is_dir() {
Ok(())
} else {
Err("Directory not found.")
}
})
.interact_text()
.unwrap();
let root_path = std::path::PathBuf::from(&root_str);
println!("{}", style("🔍 Scanning Thunderbird structure...").dim());
let mut mbox_tasks: HashMap<String, PathBuf> = HashMap::new();
fn scan_thunderbird_dir(
root: &std::path::Path,
current: &std::path::Path,
tasks: &mut HashMap<String, PathBuf>,
) {
if let Ok(entries) = std::fs::read_dir(current) {
for entry in entries.flatten() {
let path = entry.path();
let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
if path.is_dir() {
scan_thunderbird_dir(root, &path, tasks);
} else {
let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
match extension {
"msf" | "dat" | "html" | "json" | "txt" | "sqlite" => continue,
_ => {}
}
if file_name == "filterlog.html" || file_name == "msgFilterRules.dat" {
continue;
}
if !extension.is_empty() {
continue;
}
if let Ok(rel) = path.strip_prefix(root) {
let mailbox = rel.to_string_lossy().replace(".sbd", "").replace('\\', "/");
tasks.insert(mailbox, path);
}
}
}
}
}
scan_thunderbird_dir(&root_path, &root_path, &mut mbox_tasks);
if mbox_tasks.is_empty() {
println!(
"{}",
style("No mailboxes found in the specified directory.").yellow()
);
return;
}
println!("\n{}", style("🔍 Scanned Mailboxes:").bold().underlined());
let mut sorted_keys: Vec<_> = mbox_tasks.keys().collect();
sorted_keys.sort();
for name in &sorted_keys {
let path = &mbox_tasks[*name];
let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
let size_mb = file_size as f64 / 1024.0 / 1024.0;
println!(
" {} {} ({:.2} MB)",
style("").dim(),
style(name).cyan(),
size_mb
);
}
println!();
let prompt = format!("Ready to import {} mailboxes. Proceed?", mbox_tasks.len());
if Confirm::with_theme(theme)
.with_prompt(prompt)
.default(true)
.interact()
.unwrap()
{
for (mailbox_name, mbox_file) in mbox_tasks {
println!("\n🚀 Importing: {}", style(&mailbox_name).cyan().bold());
run_import(account_id, &mbox_file, config, Some(mailbox_name)).await;
}
println!(
"\n{}",
style("✨ All mailboxes imported successfully!")
.green()
.bold()
);
} else {
println!("{}", style("Import cancelled.").yellow());
}
}

View File

@@ -16,12 +16,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
modules::{
error::{code::ErrorCode, BichonResult},
settings::{cli::SETTINGS, system::SystemSetting},
token::{root::ROOT_TOKEN, AccessToken, AccountInfo},
token::AccessTokenModel,
users::{permissions::Permission, role::UserRole, UserModel},
utils::rate_limit::RATE_LIMITER_MANAGER,
},
raise_error,
@@ -35,7 +34,11 @@ use poem::{
Endpoint, FromRequest, Middleware, Request, RequestBody, Result,
};
use serde::Deserialize;
use std::{collections::BTreeSet, net::IpAddr, sync::Arc};
use std::{
collections::{BTreeSet, HashSet},
net::IpAddr,
sync::Arc,
};
use super::create_api_error_response;
@@ -68,62 +71,101 @@ impl<E: Endpoint> Endpoint for ApiGuardEndpoint<E> {
}
}
#[derive(Clone, Debug, Default)]
#[derive(Clone, Debug)]
pub struct ClientContext {
pub ip_addr: Option<IpAddr>,
pub access_token: Option<AccessToken>,
pub is_root: bool,
pub user: UserModel,
}
impl ClientContext {
pub fn require_root(&self) -> BichonResult<()> {
if !SETTINGS.bichon_enable_access_token || self.is_root {
Ok(())
} else {
Err(raise_error!(
"Root access required".into(),
ErrorCode::PermissionDenied
))
}
}
pub fn require_authorized(&self) -> BichonResult<()> {
if !SETTINGS.bichon_enable_access_token || self.is_root || self.access_token.is_some() {
Ok(())
} else {
Err(raise_error!(
"Authorization required".into(),
ErrorCode::PermissionDenied
))
}
}
pub fn require_account_access(&self, account_id: u64) -> BichonResult<()> {
if !SETTINGS.bichon_enable_access_token || self.is_root {
return Ok(());
}
match &self.access_token {
Some(token) if token.can_access_account(account_id) => Ok(()),
_ => Err(raise_error!(format!(
"You do not have permission to access the requested email account (ID: {}). Please check your access rights or contact the administrator.",
account_id
), ErrorCode::PermissionDenied)),
}
}
pub fn accessible_accounts(&self) -> BichonResult<Option<&BTreeSet<AccountInfo>>> {
if !SETTINGS.bichon_enable_access_token || self.is_root {
Ok(None) // All accounts are accessible
} else {
match &self.access_token {
Some(token) => Ok(Some(&token.accounts)),
None => Err(raise_error!(
"Missing access token".into(),
ErrorCode::PermissionDenied
)),
pub async fn require_any_permission(
&self,
requirements: Vec<(Option<u64>, &str)>,
) -> BichonResult<()> {
for (account_id, permission) in requirements {
if self.has_permission(account_id, permission).await {
return Ok(());
}
}
Err(raise_error!(
"Access denied: Insufficient permissions to perform this action.".into(),
ErrorCode::Forbidden
))
}
pub async fn has_permission(&self, account_id: Option<u64>, permission: &str) -> bool {
if self.user.is_admin().await {
return true;
}
let mut global_perms = HashSet::new();
for rid in &self.user.global_roles {
if let Some(role) = UserRole::find(*rid).await.ok().flatten() {
global_perms.extend(role.permissions);
}
}
if self.check_global_logic(&global_perms, permission) {
return true;
}
if let Some(aid) = account_id {
if let Some(role_id) = self.user.account_access_map.get(&aid) {
if let Some(role) = UserRole::find(*role_id).await.ok().flatten() {
if role.permissions.contains(&permission.to_string())
|| self.check_account_logic(&role.permissions, permission)
{
return true;
}
}
}
}
false
}
fn check_global_logic(&self, global: &HashSet<String>, perm: &str) -> bool {
if global.contains(perm) {
return true;
}
match perm {
Permission::DATA_READ => global.contains(Permission::DATA_READ_ALL),
Permission::DATA_DELETE => global.contains(Permission::DATA_DELETE_ALL),
Permission::DATA_RAW_DOWNLOAD => global.contains(Permission::DATA_RAW_DOWNLOAD_ALL),
Permission::DATA_EXPORT_BATCH => global.contains(Permission::DATA_EXPORT_BATCH_ALL),
Permission::ACCOUNT_MANAGE | Permission::ACCOUNT_READ_DETAILS => {
global.contains(Permission::ACCOUNT_MANAGE_ALL)
}
_ => false,
}
}
fn check_account_logic(&self, scoped_perms: &BTreeSet<String>, perm: &str) -> bool {
if scoped_perms.contains(perm) {
return true;
}
match perm {
Permission::DATA_READ | Permission::ACCOUNT_READ_DETAILS => {
scoped_perms.contains(Permission::ACCOUNT_MANAGE)
}
_ => false,
}
}
pub async fn require_permission(
&self,
account_id: Option<u64>,
permission: &str,
) -> BichonResult<()> {
if self.has_permission(account_id, permission).await {
Ok(())
} else {
Err(raise_error!(
format!("Access Denied: Missing permission '{}'", permission),
ErrorCode::Forbidden
))
}
}
}
@@ -134,98 +176,76 @@ impl<'a> FromRequest<'a> for ClientContext {
}
pub async fn extract_client_context(req: &Request) -> Result<ClientContext> {
if SETTINGS.bichon_enable_access_token {
let ip_addr = RealIp::from_request_without_body(req)
.await
.map_err(|_| {
create_api_error_response(
"Failed to parse client IP address",
ErrorCode::InvalidParameter,
)
})?
.0
.ok_or_else(|| {
create_api_error_response(
"Failed to parse client IP address",
ErrorCode::InvalidParameter,
)
})?;
// Extract access token from Bearer header or query params
let bearer = req
.headers()
.typed_get::<Authorization<Bearer>>()
.map(|auth| auth.0.token().to_string())
.or_else(|| req.params::<Param>().ok().map(|param| param.access_token));
let ip_addr = RealIp::from_request_without_body(req)
.await
.map_err(|_| {
create_api_error_response(
"Failed to parse client IP address",
ErrorCode::InvalidParameter,
)
})?
.0
.ok_or_else(|| {
create_api_error_response(
"Failed to parse client IP address",
ErrorCode::InvalidParameter,
)
})?;
// Extract access token from Bearer header or query params
let bearer = req
.headers()
.typed_get::<Authorization<Bearer>>()
.map(|auth| auth.0.token().to_string())
.or_else(|| req.params::<Param>().ok().map(|param| param.access_token));
let token = bearer.ok_or_else(|| {
create_api_error_response("Valid access token not found", ErrorCode::PermissionDenied)
let token = bearer.ok_or_else(|| {
create_api_error_response("Valid access token not found", ErrorCode::PermissionDenied)
})?;
// Validate and update access token
let user = AccessTokenModel::resolve_user_from_token(&token)
.await
.map_err(|e| {
create_api_error_response(&format!("{:#?}", e), ErrorCode::PermissionDenied)
})?;
// Check for root token
if let Ok(Some(root)) = SystemSetting::get(ROOT_TOKEN) {
if root.value == token {
return Ok(ClientContext {
ip_addr: Some(ip_addr),
access_token: None,
is_root: true,
});
}
}
// Validate and update access token
let validated_token = AccessToken::try_update_access_timestamp(&token)
.await
.map_err(|_| {
create_api_error_response("Invalid access token", ErrorCode::PermissionDenied)
})?;
return Ok(ClientContext {
ip_addr: Some(ip_addr),
access_token: Some(validated_token),
is_root: false,
});
}
Ok(Default::default())
return Ok(ClientContext {
ip_addr: Some(ip_addr),
user,
});
}
pub async fn authorize_access(req: &Request) -> Result<ClientContext, poem::Error> {
let context = extract_client_context(&req).await?;
context.require_authorized().map_err(|error| {
create_api_error_response(&error.to_string(), ErrorCode::PermissionDenied)
})?;
if let Some(access_token) = &context.access_token {
if let Some(access_control) = &access_token.acl {
if let Some(ip_addr) = context.ip_addr {
if let Some(whitelist) = &access_control.ip_whitelist {
if !whitelist.contains(&ip_addr.to_string()) {
return Err(create_api_error_response(
&format!("IP {} not in whitelist", ip_addr),
ErrorCode::PermissionDenied,
));
}
}
}
if let Some(rate_limit) = &access_control.rate_limit {
if let Err(not_until) = RATE_LIMITER_MANAGER
.check(&access_token.token, rate_limit.clone())
.await
{
let wait_duration = not_until.wait_time_from(QuantaClock::default().now());
if let Some(access_control) = &context.user.acl {
if let Some(ip_addr) = context.ip_addr {
if let Some(whitelist) = &access_control.ip_whitelist {
if !whitelist.contains(&ip_addr.to_string()) {
return Err(create_api_error_response(
&format!(
"Rate limit: {}/{}s. Retry after {}s",
rate_limit.quota,
rate_limit.interval,
wait_duration.as_secs()
),
ErrorCode::TooManyRequest,
&format!("IP {} not in whitelist", ip_addr),
ErrorCode::Forbidden,
));
}
}
}
if let Some(rate_limit) = &access_control.rate_limit {
if let Err(not_until) = RATE_LIMITER_MANAGER
.check(context.user.id, rate_limit.clone())
.await
{
let wait_duration = not_until.wait_time_from(QuantaClock::default().now());
return Err(create_api_error_response(
&format!(
"Rate limit: {}/{}s. Retry after {}s",
rate_limit.quota,
rate_limit.interval,
wait_duration.as_secs()
),
ErrorCode::TooManyRequest,
));
}
}
}
Ok(context)

View File

@@ -16,19 +16,14 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use super::error::code::ErrorCode;
use super::error::BichonError;
use mail_parser::{Addr as ImapAddr, Address as ImapAddress};
use mail_send::mail_builder::headers::address::Address as SmtpAddress;
use mail_send::mail_builder::headers::address::EmailAddress as SmtpEmailAddress;
use poem::error::ResponseError;
use poem::Body;
use poem::{http::StatusCode, Error, Response};
use poem_openapi::Object;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::ops::Deref;
use tracing::error;
@@ -53,38 +48,6 @@ pub struct Addr {
pub address: Option<String>,
}
impl Addr {
pub fn parse(s: &str) -> Self {
let re = Regex::new(r#"(?:(?P<name>.*)\s*)?<(?P<email>[^<>]+)>"#).unwrap();
if let Some(caps) = re.captures(s) {
let name: Option<String> = caps.name("name").map(|m| m.as_str().trim().into());
let email: Option<String> = caps.name("email").map(|m| m.as_str().trim().into());
Addr {
name: if let Some(n) = name {
if n.is_empty() {
None
} else {
Some(n)
}
} else {
None
},
address: email,
}
} else {
let s_trimmed = s.trim();
Addr {
name: None,
address: if s_trimmed.is_empty() {
None
} else {
Some(s_trimmed.into())
},
}
}
}
}
impl std::fmt::Display for Addr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match (&self.name, &self.address) {
@@ -129,50 +92,6 @@ impl<'x> From<&ImapAddress<'x>> for AddrVec {
}
}
impl<'x> From<SmtpEmailAddress<'x>> for Addr {
fn from(email: SmtpEmailAddress<'x>) -> Self {
Addr {
name: email.name.map(|n| n.into_owned()),
address: Some(email.email.into_owned()),
}
}
}
impl<'x> From<&SmtpAddress<'x>> for AddrVec {
fn from(address: &SmtpAddress<'x>) -> Self {
fn collect_addresses<'x>(address: &SmtpAddress<'x>, result: &mut Vec<Addr>) {
match address {
SmtpAddress::Address(email) => {
let addr = Addr::from(email.clone());
result.push(addr);
}
SmtpAddress::Group(group) => {
for addr in &group.addresses {
collect_addresses(addr, result);
}
}
SmtpAddress::List(list) => {
for addr in list {
collect_addresses(addr, result);
}
}
}
}
let mut addresses = Vec::new();
collect_addresses(address, &mut addresses);
AddrVec(addresses)
}
}
impl<'x> From<Addr> for SmtpAddress<'x> {
fn from(addr: Addr) -> Self {
SmtpAddress::Address(SmtpEmailAddress {
name: addr.name.map(Cow::Owned),
email: Cow::Owned(addr.address.unwrap_or_default()),
})
}
}
// #[derive(Serialize)]
// pub struct ErrorResponse {
// pub message: String,

View File

@@ -25,7 +25,7 @@ use crate::modules::error::code::ErrorCode;
use super::create_api_error_response;
pub const TIMEOUT_HEADER: &str = "X-RustMailer-Timeout-Seconds";
pub const TIMEOUT_HEADER: &str = "X-Bichon-Timeout-Seconds";
pub struct Timeout;
@@ -63,7 +63,7 @@ impl<E: Endpoint> Endpoint for TimeoutEndpoint<E> {
error!("Request timed out after {} seconds", seconds);
Err(create_api_error_response(
&format!(
"Request timed out after {} seconds (timeout set via X-RustMailer-Timeout-Seconds header, max allowed: 600 seconds)",
"Request timed out after {} seconds (timeout set via X-Bichon-Timeout-Seconds header, max allowed: 600 seconds)",
seconds
),
ErrorCode::RequestTimeout,

View File

@@ -16,7 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::migration::AccountType;
use crate::modules::context::Initialize;
use crate::modules::error::code::ErrorCode;
use crate::raise_error;
@@ -33,8 +33,7 @@ use dashmap::DashMap;
use std::sync::{Arc, LazyLock};
use tracing::info;
pub static MAIL_CONTEXT: LazyLock<EmailClientExecutors> =
LazyLock::new(EmailClientExecutors::new);
pub static MAIL_CONTEXT: LazyLock<EmailClientExecutors> = LazyLock::new(EmailClientExecutors::new);
pub struct EmailClientExecutors {
start_at: i64,
@@ -64,7 +63,7 @@ impl EmailClientExecutors {
}
let pool = build_imap_pool(account_id).await?;
let new_executor = Arc::new(ImapExecutor::new(pool));
let new_executor = Arc::new(ImapExecutor::new(account_id, pool));
match self.imap.try_entry(account_id) {
Some(dashmap::mapref::entry::Entry::Occupied(entry)) => Ok(entry.get().clone()),
@@ -88,15 +87,17 @@ impl EmailClientExecutors {
pub async fn start_account_syncers(&self) -> BichonResult<()> {
let accounts = AccountModel::list_all().await?;
let active_accounts: Vec<AccountModel> =
accounts.into_iter().filter(|a| a.enabled).collect();
let active_accounts: Vec<AccountModel> = accounts
.into_iter()
.filter(|a| a.enabled && matches!(a.account_type, AccountType::IMAP))
.collect();
if active_accounts.is_empty() {
info!("No active accounts found for account initialization.");
return Ok(());
}
info!(
"System has {} active accounts to initialize.",
"System has {} active IMAP accounts to initialize.",
active_accounts.len()
);
for account in active_accounts {

View File

@@ -16,13 +16,13 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::error::BichonResult;
pub mod controller;
pub mod executors;
pub mod status;
#[allow(async_fn_in_trait)]
pub trait Initialize {
async fn initialize() -> BichonResult<()>;
}

View File

@@ -16,14 +16,17 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::users::permissions::Permission;
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use tantivy::{schema::Value, TantivyDocument};
use crate::{
bichon_version,
modules::{
account::migration::AccountModel,
common::auth::ClientContext,
error::{code::ErrorCode, BichonResult},
indexer::{manager::ENVELOPE_INDEX_MANAGER, schema::SchemaTools},
settings::dir::DATA_DIR_MANAGER,
@@ -45,18 +48,52 @@ pub struct DashboardStats {
pub with_attachment_count: u64, // Emails with attachments
pub without_attachment_count: u64, // Emails without attachments
pub top_largest_emails: Vec<LargestEmail>, // Top 10 largest emails
pub system_version: String, // The semantic version string of the currently running backend service
pub commit_hash: String, // Git commit hash used to build this system version
}
impl DashboardStats {
pub async fn get() -> BichonResult<Self> {
let mut stat = ENVELOPE_INDEX_MANAGER.get_dashboard_stats().await?;
stat.top_largest_emails = ENVELOPE_INDEX_MANAGER.top_10_largest_emails().await?;
stat.email_count = ENVELOPE_INDEX_MANAGER.total_emails()?;
stat.account_count = AccountModel::count().await?;
stat.storage_usage_bytes = get_total_size(&DATA_DIR_MANAGER.eml_dir)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
stat.index_usage_bytes = get_total_size(&DATA_DIR_MANAGER.envelope_dir)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
pub async fn get(context: ClientContext) -> BichonResult<Self> {
let has_all_accounts = context
.has_permission(None, Permission::ACCOUNT_MANAGE_ALL)
.await;
let authorized_ids: Option<HashSet<u64>> = if has_all_accounts {
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
let mut stat = ENVELOPE_INDEX_MANAGER
.get_dashboard_stats(&authorized_ids)
.await?;
stat.top_largest_emails = ENVELOPE_INDEX_MANAGER
.top_10_largest_emails(&authorized_ids)
.await?;
stat.account_count = if has_all_accounts {
AccountModel::count().await?
} else {
authorized_ids.as_ref().map(|ids| ids.len()).unwrap_or(0)
};
stat.email_count = ENVELOPE_INDEX_MANAGER.total_emails(&authorized_ids)?;
if has_all_accounts {
stat.storage_usage_bytes = get_total_size(&DATA_DIR_MANAGER.eml_dir)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
stat.index_usage_bytes = get_total_size(&DATA_DIR_MANAGER.envelope_dir)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
} else {
stat.storage_usage_bytes = 0;
stat.index_usage_bytes = 0;
}
stat.system_version = bichon_version!().to_string();
stat.commit_hash = env!("GIT_HASH").to_string();
Ok(stat)
}
}

View File

@@ -16,11 +16,12 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::migration::AccountModel;
use crate::modules::cache::imap::MAILBOX_MODELS;
use crate::modules::error::{code::ErrorCode, BichonError};
use crate::modules::settings::cli::SETTINGS;
use crate::modules::settings::dir::DATA_DIR_MANAGER;
use crate::modules::users::UserModel;
use crate::modules::{database::META_MODELS, error::BichonResult};
use crate::raise_error;
use native_db::{Builder, Database};
@@ -71,6 +72,10 @@ impl DatabaseManager {
let rw = database
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.migrate::<AccountModel>()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.migrate::<UserModel>()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;

View File

@@ -16,8 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::migration::AccountV1;
use crate::modules::account::migration::{AccountV1, AccountV2, AccountV3};
use crate::modules::autoconfig::CachedMailSettings;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
@@ -26,7 +25,9 @@ use crate::modules::oauth2::pending::OAuth2PendingEntity;
use crate::modules::oauth2::token::OAuth2AccessToken;
use crate::modules::settings::proxy::Proxy;
use crate::modules::settings::system::SystemSetting;
use crate::modules::token::AccessToken;
use crate::modules::token::AccessTokenModel;
use crate::modules::users::role::UserRole;
use crate::modules::users::{BichonUser, BichonUserV2};
use crate::raise_error;
use db_type::{KeyOptions, ToKeyDefinition};
use itertools::Itertools;
@@ -59,14 +60,21 @@ impl ModelsAdapter {
}
pub fn register_metadata_models(&mut self) {
self.register_model::<AccessToken>();
//Starting from version 0.2.0, `AccessToken` is deprecated/no longer used, but its ID must not be reused, otherwise it may cause model errors.
//self.register_model::<AccessToken>();
self.register_model::<SystemSetting>();
self.register_model::<CachedMailSettings>();
self.register_model::<AccountV1>();
self.register_model::<AccountV2>();
self.register_model::<AccountV3>();
self.register_model::<OAuth2>();
self.register_model::<OAuth2PendingEntity>();
self.register_model::<OAuth2AccessToken>();
self.register_model::<Proxy>();
self.register_model::<UserRole>();
self.register_model::<BichonUser>();
self.register_model::<BichonUserV2>();
self.register_model::<AccessTokenModel>();
}
}
@@ -170,11 +178,11 @@ pub async fn update_impl<T: ToInput + Clone + std::fmt::Debug + Send + 'static>(
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let current_item = current(&rw)?;
let updated_item = updated(&current_item)?;
rw.update(current_item.clone(), updated_item)
rw.update(current_item, updated_item.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(current_item)
Ok(updated_item)
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
@@ -223,20 +231,20 @@ pub async fn async_find_impl<T: ToInput + Clone + Send + 'static>(
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub fn find_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
key: &str,
) -> BichonResult<Option<T>> {
let db = database.clone();
let r_transaction = db
.r_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let entity: Option<T> = r_transaction
.get()
.primary(key)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(entity)
}
// pub fn find_impl<T: ToInput + Clone + Send + 'static>(
// database: &Arc<Database<'static>>,
// key: &str,
// ) -> BichonResult<Option<T>> {
// let db = database.clone();
// let r_transaction = db
// .r_transaction()
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// let entity: Option<T> = r_transaction
// .get()
// .primary(key)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// Ok(entity)
// }
pub async fn delete_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
@@ -307,6 +315,25 @@ pub async fn list_all_impl<T: ToInput + Clone + Send + 'static>(
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub async fn with_transaction(
database: &Arc<Database<'static>>,
f: impl FnOnce(&RwTransaction) -> BichonResult<()> + Send + 'static,
) -> BichonResult<()> {
let db: Arc<Database<'_>> = database.clone();
tokio::task::spawn_blocking(move || {
let rw_transaction = db
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
f(&rw_transaction)?;
rw_transaction
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
// For tables with a creation timestamp, place the creation time at the front of the primary key.
// This allows sorting by time, as the data is stored in dictionary order based on the primary key.
// If reverse sorting by time is needed, the iterator can be reversed.

View File

@@ -16,16 +16,15 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::common::AddrVec;
use crate::modules::envelope::utils::normalize_subject;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
use crate::modules::utils::create_hash;
use crate::{calculate_hash, raise_error, utc_now};
use crate::{id, modules::indexer::envelope::Envelope};
use async_imap::types::Fetch;
use html2text::from_read;
use mail_parser::{Message, MessageParser, MimeHeaders};
use mail_parser::{HeaderName, Message, MessageParser, MimeHeaders};
pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> BichonResult<Envelope> {
let internal_date = fetch
@@ -33,12 +32,12 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
.map(|d| d.timestamp_millis())
.unwrap_or(0);
let uid = fetch.uid.unwrap_or(0);
let size = fetch.size.unwrap_or(0);
let body = fetch
.body()
.ok_or_else(|| raise_error!("No body available".into(), ErrorCode::InternalError))?;
let size = fetch.size.unwrap_or(body.len() as u32);
let message = MessageParser::new().parse(body).ok_or_else(|| {
raise_error!(
"Email header parse result is not available".into(),
@@ -49,7 +48,9 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
let text = if let Some(text) = message.body_text(0).map(|cow| cow.into_owned()) {
text
} else if let Some(html) = message.body_html(0).map(|cow| cow.into_owned()) {
from_read(html.as_bytes(), 0)
html2text::config::plain()
.allow_width_overflow()
.string_from_read(html.as_bytes(), 100)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
} else {
String::new()
@@ -62,7 +63,13 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
let in_reply_to = message.in_reply_to().as_text().map(String::from);
let references = extract_references(&message);
let thread_id = compute_thread_id(in_reply_to, references, &message_id);
let subject = message.subject().map(String::from).unwrap_or("".into());
let mut subject = message.subject().map(String::from).unwrap_or_default();
if subject.contains('\u{FFFD}') {
subject = normalize_subject(message.header_raw(HeaderName::Subject));
}
let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0);
let bcc: Option<Vec<String>> = message.bcc().map(|addr| {
AddrVec::from(addr)
@@ -93,6 +100,12 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
let attachments: Vec<String> = message
.attachments()
.filter(|att| {
let disp = att.content_disposition();
let is_inline = disp.map(|d| d.is_inline()).unwrap_or(false);
let has_filename = att.attachment_name().is_some();
has_filename && !is_inline
})
.filter_map(|att| att.attachment_name())
.map(|name| name.to_string())
.collect();
@@ -114,6 +127,109 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
thread_id,
attachments,
tags: None,
account_email: None,
mailbox_name: None,
};
Ok(envelope)
}
pub fn extract_envelope_from_eml(
body: &[u8],
account_id: u64,
mailbox_id: u64,
) -> BichonResult<Envelope> {
let uid = 0;
let size = body.len() as u32;
let message = MessageParser::new().parse(body).ok_or_else(|| {
raise_error!(
"Email header parse result is not available".into(),
ErrorCode::InternalError
)
})?;
let text = if let Some(text) = message.body_text(0).map(|cow| cow.into_owned()) {
text
} else if let Some(html) = message.body_html(0).map(|cow| cow.into_owned()) {
html2text::config::plain()
.allow_width_overflow()
.string_from_read(html.as_bytes(), 100)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
} else {
String::new()
};
let message_id = message
.message_id()
.map(String::from)
.unwrap_or(generate_message_id());
let in_reply_to = message.in_reply_to().as_text().map(String::from);
let references = extract_references(&message);
let thread_id = compute_thread_id(in_reply_to, references, &message_id);
let mut subject = message.subject().map(String::from).unwrap_or_default();
if subject.contains('\u{FFFD}') {
subject = normalize_subject(message.header_raw(HeaderName::Subject));
}
let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0);
let bcc: Option<Vec<String>> = message.bcc().map(|addr| {
AddrVec::from(addr)
.0
.into_iter()
.filter_map(|a| a.address)
.collect()
});
let cc: Option<Vec<String>> = message.cc().map(|addr| {
AddrVec::from(addr)
.0
.into_iter()
.filter_map(|a| a.address)
.collect()
});
let to: Option<Vec<String>> = message.to().map(|addr| {
AddrVec::from(addr)
.0
.into_iter()
.filter_map(|a| a.address)
.collect()
});
let from = message
.from()
.and_then(|addr| AddrVec::from(addr).0.into_iter().next())
.and_then(|add| add.address)
.unwrap_or_else(|| "unknown".to_string());
let attachments: Vec<String> = message
.attachments()
.filter(|att| {
let disp = att.content_disposition();
let is_inline = disp.map(|d| d.is_inline()).unwrap_or(false);
let has_filename = att.attachment_name().is_some();
has_filename && !is_inline
})
.filter_map(|att| att.attachment_name())
.map(|name| name.to_string())
.collect();
let envelope = Envelope {
id: create_hash(account_id, &message_id),
message_id,
account_id,
mailbox_id,
uid,
subject,
text,
from,
to: to.unwrap_or_default(),
cc: cc.unwrap_or_default(),
bcc: bcc.unwrap_or_default(),
date,
internal_date: date,
size,
thread_id,
attachments,
tags: None,
account_email: None,
mailbox_name: None,
};
Ok(envelope)
}
@@ -144,3 +260,52 @@ fn extract_references(message: &Message<'_>) -> Option<Vec<String>> {
_ => None,
}
}
#[cfg(test)]
mod test {
use html2text::config;
#[test]
fn test_various_html_with_overflow_enabled() {
let cases = [
("<p>Hello World</p>", "Simple paragraph"),
("<h1>Title</h1><p>Content</p>", "Heading + paragraph"),
("<ul><li>Item1</li><li>Item2</li></ul>", "Unordered list"),
(
"<strong>Bold</strong> and <em>italic</em>",
"Inline formatting",
),
(
"<div><span>Nested</span> elements</div>",
"Nested inline elements inside block",
),
(
"<table><tr><td>A</td><td>B</td></tr></table>",
"Simple table",
),
(
"<pre> preformatted text\n line2</pre>",
"Preformatted block",
),
("😃 emoji test", "Wide emoji"),
("<a href=\"#\">link</a>", "Anchor tag"),
(
"<blockquote><p>Quoted text</p></blockquote>",
"Blockquote with paragraph",
),
];
for (html, desc) in cases {
let result = config::plain()
.allow_width_overflow()
.string_from_read(html.as_bytes(), 100);
match result {
Ok(output) => {
println!("✓ Rendered ({}) =>\n{}", desc, output);
}
Err(e) => panic!("Unexpected error for {}: {:?}", desc, e),
}
}
}
}

View File

@@ -16,5 +16,5 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod extractor;
pub mod utils;

View File

@@ -0,0 +1,114 @@
use mail_parser::parsers::MessageStream;
use regex::{Captures, Regex};
fn merge_contiguous_encoded_words(input: &str) -> String {
let block_re =
Regex::new(r"(?:=\?[^?]+\?[bBqQ]\?[^?]+\?=)(?:\s+(?:=\?[^?]+\?[bBqQ]\?[^?]+\?=))+")
.unwrap();
let word_re = Regex::new(r"=\?([^?]+)\?([bBqQ])\?([^?]+)\?=").unwrap();
block_re
.replace_all(input, |caps: &Captures| {
let whole = caps.get(0).unwrap().as_str();
let mut charset: Option<String> = None;
let mut encoding: Option<String> = None;
let mut combined = String::new();
let mut ok = true;
for cap in word_re.captures_iter(whole) {
let cs = &cap[1];
let enc = cap[2].to_ascii_uppercase();
let text = &cap[3];
if let Some(ref c) = charset {
if c != cs {
ok = false;
break;
}
} else {
charset = Some(cs.to_string());
}
if let Some(ref e) = encoding {
if e != &enc {
ok = false;
break;
}
} else {
encoding = Some(enc);
}
combined.push_str(text);
}
if ok {
format!(
"=?{}?{}?{}?=",
charset.unwrap(),
encoding.unwrap(),
combined
)
} else {
whole.to_string()
}
})
.to_string()
}
pub fn normalize_subject(raw_subject: Option<&str>) -> String {
let subject = match raw_subject {
Some(subject) => merge_contiguous_encoded_words(subject),
None => return String::new(),
};
MessageStream::new(subject.as_bytes())
.parse_unstructured()
.as_text()
.map(String::from)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use crate::modules::envelope::utils::merge_contiguous_encoded_words;
#[tokio::test]
async fn test3() {
let s = "Hello =?UTF-8?B?SGVsbG8=?= =?UTF-8?B?V29ybGQ=?= !!!";
assert_eq!(
merge_contiguous_encoded_words(s),
"Hello =?UTF-8?B?SGVsbG8=V29ybGQ=?= !!!"
);
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?= =?UTF-8?B?Qw==?=";
assert_eq!(
merge_contiguous_encoded_words(s),
"=?UTF-8?B?QQ==Qg==Qw==?="
);
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?= test =?UTF-8?B?Qw==?= =?UTF-8?B?RA==?=";
assert_eq!(
merge_contiguous_encoded_words(s),
"=?UTF-8?B?QQ==Qg==?= test =?UTF-8?B?Qw==RA==?="
);
let s = "=?UTF-8?B?QQ==?= =?GBK?B?Qg==?=";
assert_eq!(merge_contiguous_encoded_words(s), s);
let s = "=?UTF-8?B?QQ==?= =?UTF-8?Q?Qg?=";
assert_eq!(merge_contiguous_encoded_words(s), s);
let s = "=?UTF-8?b?QQ==?= =?UTF-8?B?Qg==?=";
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?B?QQ==Qg==?=");
let s = "Hello =?UTF-8?B?SGVsbG8=?= !!!";
assert_eq!(merge_contiguous_encoded_words(s), s);
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?=";
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?B?QQ==Qg==?=");
let s = "Just a normal subject line";
assert_eq!(merge_contiguous_encoded_words(s), s);
let s = "=?UTF-8?Q?Hello_?= =?UTF-8?Q?World?=";
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?Q?Hello_World?=");
}
}

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use poem::http::StatusCode;
use poem_openapi::Enum;
@@ -34,12 +33,14 @@ pub enum ErrorCode {
// Authentication and authorization errors (2000020999)
PermissionDenied = 20000,
AccountDisabled = 20010,
Forbidden = 20020,
OAuth2ItemDisabled = 20050,
MissingRefreshToken = 20060,
// Resource errors (3000030999)
ResourceNotFound = 30000,
TooManyRequest = 30020,
AlreadyExists = 30030,
// Network connection errors (4000040999)
NetworkError = 40000,
@@ -64,11 +65,14 @@ impl ErrorCode {
| ErrorCode::MissingConfiguration
| ErrorCode::Incompatible => StatusCode::BAD_REQUEST,
ErrorCode::PermissionDenied => StatusCode::UNAUTHORIZED,
ErrorCode::AccountDisabled | ErrorCode::OAuth2ItemDisabled => StatusCode::FORBIDDEN,
ErrorCode::AccountDisabled | ErrorCode::OAuth2ItemDisabled | ErrorCode::Forbidden => {
StatusCode::FORBIDDEN
}
ErrorCode::ResourceNotFound => StatusCode::NOT_FOUND,
ErrorCode::RequestTimeout => StatusCode::REQUEST_TIMEOUT,
ErrorCode::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
ErrorCode::TooManyRequest => StatusCode::TOO_MANY_REQUESTS,
ErrorCode::AlreadyExists => StatusCode::CONFLICT,
ErrorCode::InternalError
| ErrorCode::AutoconfigFetchFailed
| ErrorCode::ImapCommandFailed

View File

@@ -16,15 +16,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{fmt::Formatter, u32};
use crate::raise_error;
use bb8::RunError;
use code::ErrorCode;
use poem::http::StatusCode;
use poem_openapi::{payload::Json, ApiResponse, Object};
use snafu::{Location, Snafu};
use std::{fmt::Formatter, u32};
pub mod code;
pub mod handler;
@@ -43,17 +39,6 @@ pub enum BichonError {
pub type BichonResult<T, E = BichonError> = std::result::Result<T, E>;
impl From<RunError<BichonError>> for BichonError {
fn from(e: RunError<BichonError>) -> Self {
match e {
RunError::User(e) => e,
RunError::TimedOut => raise_error!(
"Timed out while attempting to acquire a connection from the pool".into(),
ErrorCode::ConnectionPoolTimeout
),
}
}
}
#[derive(Debug, Clone, Object)]
pub struct ApiError {
pub message: String,

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::error::code::ErrorCode;
use crate::modules::imap::session::SessionStream;
use crate::{modules::error::BichonResult, raise_error};

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::entity::Encryption;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
@@ -100,15 +99,17 @@ impl Client {
encryption: &Encryption,
port: u16,
use_proxy: Option<u64>,
dangerous: bool,
) -> BichonResult<Self> {
let resolved_addr = Self::resolve_to_socket_addr(domain, port)?;
debug!("Attempting IMAP connection to {domain} ({resolved_addr}).");
match encryption {
Encryption::Ssl => {
Self::establish_secure_connection(resolved_addr, domain, use_proxy).await
Self::establish_secure_connection(resolved_addr, domain, use_proxy, dangerous).await
}
Encryption::StartTls => {
Self::establish_starttls_connection(resolved_addr, domain, use_proxy).await
Self::establish_starttls_connection(resolved_addr, domain, use_proxy, dangerous)
.await
}
Encryption::None => Self::establish_insecure_connection(resolved_addr, use_proxy).await,
}
@@ -118,11 +119,17 @@ impl Client {
address: SocketAddr,
server_hostname: &str,
use_proxy: Option<u64>,
dangerous: bool,
) -> BichonResult<Self> {
// Establish the TLS connection with the specified parameters
let tls_stream =
establish_tls_connection(address, server_hostname, alpn(address.port()), use_proxy)
.await?;
let tls_stream = establish_tls_connection(
address,
server_hostname,
alpn(address.port()),
use_proxy,
dangerous,
)
.await?;
let stats_stream = StatsWrapper::new(tls_stream);
// Wrap the TLS stream in a buffered writer for efficient IO
let buffered_stream = BufWriter::new(stats_stream);
@@ -137,7 +144,7 @@ impl Client {
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
.ok_or_else(|| {
raise_error!(
"failed to read greeting".into(),
"Failed to read IMAP greeting — this usually indicates an incorrect encryption setting (SSL vs. STARTTLS). Your current setting is SSL.".into(),
ErrorCode::ImapCommandFailed
)
})?;
@@ -180,6 +187,7 @@ impl Client {
address: SocketAddr,
server_hostname: &str,
use_proxy: Option<u64>,
dangerous: bool,
) -> BichonResult<Self> {
// Establish the initial TCP connection
let tcp_stream = establish_tcp_connection_with_timeout(address, use_proxy).await?;
@@ -197,7 +205,7 @@ impl Client {
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
.ok_or_else(|| {
raise_error!(
"failed to read greeting".into(),
"Failed to read IMAP greeting — this usually indicates an incorrect encryption setting (SSL vs. STARTTLS). Your current setting is STARTTLS.".into(),
ErrorCode::ImapCommandFailed
)
})?;
@@ -217,7 +225,7 @@ impl Client {
let buffered_tcp_stream = client.into_inner();
let tcp_stream = buffered_tcp_stream.into_inner();
// Wrap the TCP stream in TLS encryption
let tls_stream = establish_tls_stream(server_hostname, &[], tcp_stream).await?;
let tls_stream = establish_tls_stream(server_hostname, &[], tcp_stream, dangerous).await?;
// Wrap the TLS stream in a buffered writer
let buffered_stream = BufWriter::new(tls_stream);
// Create a SessionStream trait object for further communication

View File

@@ -16,10 +16,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::migration::AccountModel;
use crate::modules::account::state::AccountRunningState;
use crate::modules::cache::imap::mailbox::MailBox;
use crate::modules::cache::imap::sync::flow::{generate_uid_sequence_hashset, BATCH_SIZE};
use crate::modules::cache::imap::sync::flow::{generate_uid_sequence_hashset, DEFAULT_BATCH_SIZE};
use crate::modules::envelope::extractor::extract_envelope;
use crate::modules::error::code::ErrorCode;
use crate::modules::indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER};
@@ -27,7 +27,7 @@ use crate::modules::indexer::schema::SchemaTools;
use crate::modules::{error::BichonResult, imap::manager::ImapConnectionManager};
use crate::raise_error;
use async_imap::types::{Mailbox, Name};
use bb8::Pool;
use bb8::{Pool, RunError};
use futures::TryStreamExt;
use std::collections::HashSet;
use tantivy::doc;
@@ -36,16 +36,17 @@ use tracing::info;
const BODY_FETCH_COMMAND: &str = "(UID INTERNALDATE RFC822.SIZE BODY.PEEK[])";
pub struct ImapExecutor {
account_id: u64,
pool: Pool<ImapConnectionManager>,
}
impl ImapExecutor {
pub fn new(pool: Pool<ImapConnectionManager>) -> Self {
Self { pool }
pub fn new(account_id: u64, pool: Pool<ImapConnectionManager>) -> Self {
Self { account_id, pool }
}
pub async fn list_all_mailboxes(&self) -> BichonResult<Vec<Name>> {
let mut session = self.pool.get().await?;
let mut session = self.get_connection().await?;
let list = session
.list(Some(""), Some("*"))
.await
@@ -58,7 +59,7 @@ impl ImapExecutor {
}
pub async fn examine_mailbox(&self, mailbox_name: &str) -> BichonResult<Mailbox> {
let mut session = self.pool.get().await?;
let mut session = self.get_connection().await?;
session
.examine(mailbox_name)
.await
@@ -66,7 +67,7 @@ impl ImapExecutor {
}
pub async fn uid_search(&self, mailbox_name: &str, query: &str) -> BichonResult<HashSet<u32>> {
let mut session = self.pool.get().await?;
let mut session = self.get_connection().await?;
session
.examine(mailbox_name)
.await
@@ -78,19 +79,35 @@ impl ImapExecutor {
Ok(result)
}
pub async fn append(
&self,
mailbox_name: impl AsRef<str>,
flags: Option<&str>,
internaldate: Option<&str>,
content: impl AsRef<[u8]>,
) -> BichonResult<()> {
let mut session = self.get_connection().await?;
session
.append(mailbox_name, flags, internaldate, content)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))
}
pub async fn fetch_new_mail(
&self,
account_id: u64,
account: &AccountModel,
mailbox: &MailBox,
start_uid: u64,
before: Option<&str>,
) -> BichonResult<()> {
assert!(start_uid > 0, "start_uid must be greater than 0");
let uid_list = self
.uid_search(
&mailbox.encoded_name(),
format!("UID {start_uid}:*").as_str(),
)
.await?;
let query = match before {
Some(date) => format!("UID {start_uid}:* BEFORE {date}"),
None => format!("UID {start_uid}:*"),
};
let uid_list = self.uid_search(&mailbox.encoded_name(), &query).await?;
let len = uid_list.len();
if len == 0 {
@@ -98,17 +115,21 @@ impl ImapExecutor {
}
info!(
"[account {}][mailbox {}] {} envelopes need to be fetched",
account_id, mailbox.name, len
account.id, mailbox.name, len
);
let mut uid_vec: Vec<u32> = uid_list.into_iter().collect();
uid_vec.sort();
let uid_batches = generate_uid_sequence_hashset(uid_vec, BATCH_SIZE as usize, false);
let uid_batches = generate_uid_sequence_hashset(
uid_vec,
account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize,
false,
);
let too_many = len as u32 > 10 * BATCH_SIZE;
let too_many = len as u32 > 5 * account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE);
if too_many {
AccountRunningState::set_initial_current_syncing_folder(
account_id,
account.id,
mailbox.name.clone(),
uid_batches.len() as u32,
)
@@ -118,13 +139,13 @@ impl ImapExecutor {
for (index, batch) in uid_batches.into_iter().enumerate() {
if too_many {
AccountRunningState::set_current_sync_batch_number(
account_id,
account.id,
mailbox.name.clone(),
(index + 1) as u32,
)
.await?;
}
self.uid_batch_retrieve_emails(account_id, mailbox.id, &batch, &mailbox.encoded_name())
self.uid_batch_retrieve_emails(account.id, mailbox.id, &batch, &mailbox.encoded_name())
.await?;
}
Ok(())
@@ -142,7 +163,7 @@ impl ImapExecutor {
assert!(page > 0, "Page number must be greater than 0");
assert!(page_size > 0, "Page size must be greater than 0");
let mut session = self.pool.get().await?;
let mut session = self.get_connection().await?;
let total = session
.examine(encoded_mailbox_name)
.await
@@ -211,7 +232,7 @@ impl ImapExecutor {
uid_set: &str,
encoded_mailbox_name: &str,
) -> BichonResult<()> {
let mut session = self.pool.get().await?;
let mut session = self.get_connection().await?;
session
.examine(encoded_mailbox_name)
.await
@@ -238,4 +259,41 @@ impl ImapExecutor {
}
Ok(())
}
async fn get_connection(
&self,
) -> BichonResult<bb8::PooledConnection<'_, ImapConnectionManager>> {
match self.pool.get().await {
Ok(connection) => Ok(connection),
Err(e) => match e {
RunError::User(e) => Err(e),
RunError::TimedOut => {
let state = self.pool.state();
tracing::warn!(
"{}: connections={}, idle={}, \
get_started={}, get_direct={}, get_waited={}, get_timed_out={}, \
wait_time_ms={}, created={}, closed_broken={}, closed_invalid={}, \
closed_lifetime={}, closed_idle={}",
self.account_id,
state.connections,
state.idle_connections,
state.statistics.get_started,
state.statistics.get_direct,
state.statistics.get_waited,
state.statistics.get_timed_out,
state.statistics.get_wait_time.as_millis(),
state.statistics.connections_created,
state.statistics.connections_closed_broken,
state.statistics.connections_closed_invalid,
state.statistics.connections_closed_max_lifetime,
state.statistics.connections_closed_idle_timeout,
);
return Err(raise_error!(
"Timed out while attempting to acquire a connection from the pool".into(),
ErrorCode::ConnectionPoolTimeout
));
}
},
}
}
}

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::dispatcher::STATUS_DISPATCHER;
use crate::modules::account::entity::AuthType;
use crate::modules::account::migration::{AccountModel, AccountType};
@@ -29,7 +28,7 @@ use crate::modules::imap::client::Client;
use crate::modules::imap::oauth2::OAuth2;
use crate::modules::imap::session::SessionStream;
use crate::modules::oauth2::token::OAuth2AccessToken;
use crate::{decrypt, raise_error};
use crate::{bichon_version, decrypt, raise_error};
use async_imap::Session;
use tracing::error;
@@ -51,7 +50,14 @@ impl ImapConnectionManager {
async fn create_client(&self, account: &AccountModel) -> BichonResult<Client> {
assert_eq!(account.account_type, AccountType::IMAP);
let imap = account.imap.as_ref().unwrap();
Client::connection(&imap.host, &imap.encryption, imap.port, imap.use_proxy).await
Client::connection(
&imap.host,
&imap.encryption,
imap.port,
imap.use_proxy,
account.use_dangerous,
)
.await
}
async fn authenticate(
@@ -61,6 +67,7 @@ impl ImapConnectionManager {
) -> BichonResult<Session<Box<dyn SessionStream>>> {
assert_eq!(account.account_type, AccountType::IMAP);
let imap = account.imap.as_ref().unwrap();
let username = account.name.clone().unwrap_or(account.email.clone());
match &imap.auth.auth_type {
AuthType::Password => {
let password = &imap.auth.password.clone().ok_or_else(|| {
@@ -71,7 +78,13 @@ impl ImapConnectionManager {
})?;
let password = decrypt!(&password)?;
client.login(&account.email, &password).await
client.login(&username, &password).await.map_err(|e| {
error!(
"IMAP password auth failed for username '{}': {}",
username, e
);
e
})
}
AuthType::OAuth2 => {
let record = OAuth2AccessToken::get(self.account_id).await?;
@@ -83,8 +96,12 @@ impl ImapConnectionManager {
)
})?;
client
.authenticate(OAuth2::new(account.email.clone(), access_token))
.authenticate(OAuth2::new(username.clone(), access_token))
.await
.map_err(|e| {
error!("IMAP OAuth2 auth failed for username '{}': {}", username, e);
e
})
}
}
}
@@ -137,6 +154,19 @@ impl ImapConnectionManager {
.await;
return Err(error);
}
if capabilities.has_str("ID") || capabilities.has_str("id") {
session
.id([
("name", Some("bichon")),
("version", Some(bichon_version!())),
("vendor", Some("rustmailer")),
])
.await
.map_err(|e| {
raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed)
})?;
}
}
Err(error) => {
error!("Failed to fetch IMAP capabilities: {:#?}", error);

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::error::code::ErrorCode;
use crate::modules::error::{BichonError, BichonResult};
use crate::modules::imap::{manager::ImapConnectionManager, session::SessionStream};
@@ -49,7 +48,7 @@ pub async fn build_imap_pool(account_id: u64) -> BichonResult<Pool<ImapConnectio
let manager = ImapConnectionManager::new(account_id);
let pool = Pool::builder()
.connection_timeout(Duration::from_secs(30))
.idle_timeout(Duration::from_secs(120))
//.idle_timeout(Duration::from_secs(120))
.retry_connection(true)
.max_size(10)
.test_on_check_out(true)

View File

@@ -16,19 +16,23 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use mail_parser::{parsers::MessageStream, HeaderName, MessageParser};
use mail_parser::MessageParser;
use crate::{base64_encode_url_safe, modules::{account::entity::Encryption, imap::client::Client}};
use crate::{
base64_encode_url_safe,
modules::{
account::entity::Encryption, envelope::utils::normalize_subject, imap::client::Client,
},
};
#[tokio::test]
async fn testxx() {
rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider())
.unwrap();
let client = Client::connection("imap.zoho.com".into(), &Encryption::Ssl, 993, None)
let client = Client::connection("imap.zoho.com".into(), &Encryption::Ssl, 993, None, false)
.await
.unwrap();
let mut session = client.login("pollybase@zohomail.com", "xxx").await.unwrap();
let mut session = client.login("xx@zohomail.com", "xxx").await.unwrap();
session.select("INBOX").await.unwrap();
let result = session.uid_search("LARGER 1024").await.unwrap();
println!("{:#?}", result);
@@ -46,3 +50,72 @@ async fn test1() {
println!("{}", part.is_multipart());
}
}
#[tokio::test]
async fn test2() {
const MESSAGE: &str = r#"From: Art Vandelay <art@vandelay.com> (Vandelay Industries)
To: "Colleagues": "James Smythe" <james@vandelay.com>; Friends:
jane@example.com, =?UTF-8?Q?John_Sm=C3=AEth?= <john@example.com>;
Date: Sat, 20 Nov 2021 14:22:01 -0800
Subject: =?utf-8?B?SnVzdCAxNSBkYXlzIGxlZnQgdG8gdmlzaXQgTkFSTklBISDinYTvuI/wn462?=
Content-Type: multipart/mixed; boundary="festivus";
--festivus
Content-Type: text/html; charset="us-ascii"
Content-Transfer-Encoding: base64
PGh0bWw+PHA+SSB3YXMgdGhpbmtpbmcgYWJvdXQgcXVpdHRpbmcgdGhlICZsZHF1bztle
HBvcnRpbmcmcmRxdW87IHRvIGZvY3VzIGp1c3Qgb24gdGhlICZsZHF1bztpbXBvcnRpbm
cmcmRxdW87LDwvcD48cD5idXQgdGhlbiBJIHRob3VnaHQsIHdoeSBub3QgZG8gYm90aD8
gJiN4MjYzQTs8L3A+PC9odG1sPg==
--festivus
Content-Type: message/rfc822
From: "Cosmo Kramer" <kramer@kramerica.com>
Subject: Exporting my book about coffee tables
Content-Type: multipart/mixed; boundary="giddyup";
--giddyup
Content-Type: text/plain; charset="utf-16"
Content-Transfer-Encoding: quoted-printable
=FF=FE=0C!5=D8"=DD5=D8)=DD5=D8-=DD =005=D8*=DD5=D8"=DD =005=D8"=
=DD5=D85=DD5=D8-=DD5=D8,=DD5=D8/=DD5=D81=DD =005=D8*=DD5=D86=DD =
=005=D8=1F=DD5=D8,=DD5=D8,=DD5=D8(=DD =005=D8-=DD5=D8)=DD5=D8"=
=DD5=D8=1E=DD5=D80=DD5=D8"=DD!=00
--giddyup
Content-Type: image/gif; name*1="about "; name*0="Book ";
name*2*=utf-8''%e2%98%95 tables.gif
Content-Transfer-Encoding: Base64
Content-Disposition: attachment
R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7
--giddyup--
--festivus--
"#;
let message = MessageParser::default().parse(MESSAGE).unwrap();
let raw_subject = message.header_raw("Subject").unwrap().as_bytes();
let data = MessageStream::new(raw_subject)
.parse_unstructured()
.unwrap_text()
.to_string();
println!("{}", data);
// RFC2047 support for encoded text in message readers
println!("{}", message.subject().unwrap());
}
#[tokio::test]
async fn test44() {
let path = r"C:\Users\polly\Downloads\test222.eml";
let input = std::fs::read(path).unwrap();
let message = MessageParser::default().parse(&input).unwrap();
let subject = message.subject().unwrap();
println!("Subject: {}", subject);
if subject.contains('\u{FFFD}') {
let subject = normalize_subject(message.header_raw(HeaderName::Subject));
println!("Subject: {}", subject);
}
}

161
src/modules/import/mod.rs Normal file
View File

@@ -0,0 +1,161 @@
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use tantivy::doc;
use crate::{
base64_decode_url_safe,
modules::{
account::migration::{AccountModel, AccountType},
cache::imap::mailbox::{Attribute, AttributeEnum, MailBox},
envelope::extractor::extract_envelope_from_eml,
error::{code::ErrorCode, BichonResult},
indexer::{
manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
schema::SchemaTools,
},
utils::create_hash,
},
raise_error,
};
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct BatchEmlRequest {
pub account_id: u64,
pub mail_folder: String,
/// A list of emails in base64-encoded format. Each element represents one .eml file.
pub emls: Vec<String>,
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct FailedEmlDetail {
/// The 0-based index of the failed EML in the request list
pub index: usize,
/// The error message that caused the import to fail
pub error_message: String,
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct BatchEmlResult {
/// Total number of emails processed
pub total: usize,
/// Number of emails successfully imported
pub success: usize,
/// Number of emails failed to import
pub failed: usize,
/// A list of details for failed imports
pub failed_details: Vec<FailedEmlDetail>,
}
pub struct ImportEmls;
impl ImportEmls {
pub async fn do_import(request: BatchEmlRequest) -> BichonResult<BatchEmlResult> {
let account = AccountModel::check_account_exists(request.account_id).await?;
if !account.enabled {
return Err(raise_error!("The account is disabled and cannot be used for this operation.".into(), ErrorCode::InvalidParameter));
}
let mailbox_id = match account.account_type {
AccountType::IMAP => {
let all_mailboxes = MailBox::list_all(account.id).await?;
let mailbox = all_mailboxes.into_iter().find(|m| m.name == request.mail_folder);
match mailbox {
Some(mailbox) => mailbox.id,
None => return Err(raise_error!(
format!("Mail folder '{}' not found for account ID {}. The target folder must exist before importing.",
request.mail_folder,
request.account_id).into(),
ErrorCode::ResourceNotFound
)),
}
},
AccountType::NoSync => {
let mailbox = MailBox {
id: create_hash(request.account_id, &request.mail_folder),
account_id: request.account_id,
name: request.mail_folder.clone(),
delimiter: Some("/".to_string()),
attributes: vec![Attribute {
attr: AttributeEnum::Extension,
extension: Some("CreatedByBichon".into()),
}],
exists: 0,
unseen: None,
uid_next: None,
uid_validity: None,
};
let mailbox_id = mailbox.id;
// Upsert the mailbox, creating it if it doesn't exist
MailBox::batch_upsert(&[mailbox]).await?;
mailbox_id
},
};
let fields = SchemaTools::eml_fields();
let account_id = account.id;
let mut success_count = 0;
let mut failed_details: Vec<FailedEmlDetail> = Vec::new(); // Store failure details
let total = request.emls.len();
for (index, eml_base64) in request.emls.into_iter().enumerate() {
let decoded = match base64_decode_url_safe!(eml_base64.as_bytes()) {
Ok(bytes) => bytes,
Err(e) => {
let error_msg =
format!("Failed to decode base64 EML at index {}: {:?}", index, e);
tracing::error!("{}", error_msg);
failed_details.push(FailedEmlDetail {
index,
error_message: error_msg,
});
continue;
}
};
let envelope = match extract_envelope_from_eml(&decoded, account_id, mailbox_id) {
Ok(env) => env,
Err(e) => {
let error_msg = format!(
"Failed to extract envelope from EML at index {}: {:?}",
index, e
);
tracing::error!("{}", error_msg);
failed_details.push(FailedEmlDetail {
index,
error_message: error_msg,
});
continue;
}
};
ENVELOPE_INDEX_MANAGER
.add_document(envelope.id, envelope.to_document(mailbox_id).unwrap())
.await;
EML_INDEX_MANAGER
.add_document(
envelope.id,
doc!(
fields.f_id => envelope.id,
fields.f_account_id => account_id,
fields.f_mailbox_id => mailbox_id,
fields.f_eml => decoded
),
)
.await;
success_count += 1;
}
let failed_count = failed_details.len();
Ok(BatchEmlResult {
total,
success: success_count,
failed: failed_count,
failed_details, // Return the list of failure details
})
}
}

View File

@@ -16,7 +16,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::HashSet;
use crate::modules::account::migration::AccountModel;
use crate::modules::cache::imap::mailbox::MailBox;
use crate::modules::error::code::ErrorCode;
use crate::modules::utils::create_hash;
use crate::modules::{error::BichonResult, indexer::schema::SchemaTools};
@@ -31,7 +34,9 @@ pub struct Envelope {
pub id: u64,
pub message_id: String,
pub account_id: u64,
pub account_email: Option<String>,
pub mailbox_id: u64,
pub mailbox_name: Option<String>,
pub uid: u32,
pub subject: String,
pub text: String,
@@ -152,9 +157,9 @@ impl Envelope {
let id = create_hash(account_id, &message_id);
let full_text = extract_string_field(doc, fields.f_text)?;
// Take up to the first 120 characters as a preview;
let preview = if full_text.chars().count() > 120 {
full_text.chars().take(120).collect::<String>() + "..."
// Take up to the first 500 characters as a preview;
let preview = if full_text.chars().count() > 500 {
full_text.chars().take(500).collect::<String>() + "..."
} else {
full_text
};
@@ -169,11 +174,20 @@ impl Envelope {
})
.flatten()
.collect();
let account_email = AccountModel::find(account_id).await?.map(|a| a.email);
let mailboxes = MailBox::list_all(account_id).await?;
let mailbox_name = mailboxes
.iter()
.find(|m| m.id == mailbox_id)
.map(|m| m.name.clone());
let envelope = Envelope {
id,
account_id,
account_email,
mailbox_id,
mailbox_name,
message_id: extract_string_field(doc, fields.f_message_id)?,
uid: extract_u64_field(doc, fields.f_uid)? as u32,
subject: extract_string_field(doc, fields.f_subject)?,
@@ -192,3 +206,28 @@ impl Envelope {
Ok(envelope)
}
}
pub async fn extract_contacts(doc: &TantivyDocument) -> BichonResult<HashSet<String>> {
let fields = SchemaTools::envelope_fields();
let mut all_contacts = HashSet::new();
if let Ok(from_val) = extract_string_field(doc, fields.f_from) {
if !from_val.is_empty() {
all_contacts.insert(from_val);
}
}
let multi_fields = [fields.f_to, fields.f_cc, fields.f_bcc];
for field in multi_fields {
if let Ok(vals) = extract_vec_string_field(doc, field) {
for v in vals {
if !v.is_empty() {
all_contacts.insert(v);
}
}
}
}
Ok(all_contacts)
}

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{
collections::{HashMap, HashSet},
ops::Bound,
@@ -25,7 +24,10 @@ use std::{
time::Duration,
};
use crate::modules::message::tags::TagCount;
use crate::modules::{
indexer::envelope::extract_contacts,
message::{search::SortBy, tags::TagCount},
};
use crate::{
modules::{
account::migration::AccountModel,
@@ -35,8 +37,8 @@ use crate::{
indexer::{
envelope::Envelope,
fields::{
F_ACCOUNT_ID, F_FROM, F_HAS_ATTACHMENT, F_INTERNAL_DATE, F_MAILBOX_ID, F_SIZE,
F_TAGS, F_THREAD_ID, F_UID,
F_ACCOUNT_ID, F_DATE, F_FROM, F_HAS_ATTACHMENT, F_MAILBOX_ID, F_SIZE, F_TAGS,
F_THREAD_ID, F_UID,
},
schema::SchemaTools,
},
@@ -58,7 +60,10 @@ use tantivy::{
AggregationCollector, Key,
},
collector::{Count, FacetCollector, TopDocs},
query::{AllQuery, BooleanQuery, Occur, Query, QueryParser, RangeQuery, TermQuery},
query::{
AllQuery, BooleanQuery, EmptyQuery, Occur, Query, QueryParser, RangeQuery, RegexQuery,
TermQuery,
},
schema::{Facet, IndexRecordOption, Value},
store::{Compressor, ZstdCompressor},
DocAddress, Index, IndexBuilder, IndexReader, IndexSettings, IndexWriter, Order,
@@ -71,6 +76,7 @@ use tokio::{
sync::{mpsc, Mutex},
task,
};
use tracing::info;
pub static ENVELOPE_INDEX_MANAGER: LazyLock<EnvelopeIndexManager> =
LazyLock::new(EnvelopeIndexManager::new);
@@ -183,20 +189,50 @@ impl EnvelopeIndexManager {
}
fn open_or_create_index(index_dir: &PathBuf) -> Index {
if !index_dir.exists() {
let need_create = !index_dir.exists()
|| index_dir
.read_dir()
.map(|mut d| d.next().is_none())
.unwrap_or(true);
if need_create {
info!(
"Email index not found or empty, creating new index at {}",
index_dir.display()
);
std::fs::create_dir_all(&index_dir).unwrap_or_else(|e| {
panic!("Failed to create index directory {:?}: {}", index_dir, e)
});
Index::create_in_dir(&index_dir, SchemaTools::envelope_schema())
.unwrap_or_else(|e| panic!("Failed to create index in {:?}: {}", index_dir, e))
} else {
info!("Opening existing email index at {}", index_dir.display());
open(&index_dir)
}
}
pub fn total_emails(&self) -> BichonResult<u64> {
pub fn total_emails(&self, accounts: &Option<HashSet<u64>>) -> BichonResult<u64> {
let searcher = self.create_searcher()?;
Ok(searcher.num_docs())
match accounts {
Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new();
for &id in ids {
let term =
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
subqueries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
let query = Box::new(BooleanQuery::new(subqueries)) as Box<dyn Query>;
let count = searcher
.search(&query, &Count)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(count as u64)
}
Some(_) => Ok(0),
None => Ok(searcher.num_docs()),
}
}
fn account_query(&self, account_id: u64) -> Box<TermQuery> {
@@ -223,12 +259,36 @@ impl EnvelopeIndexManager {
fn filter_query(
&self,
accounts: Option<HashSet<u64>>,
filter: SearchFilter,
parser: QueryParser,
) -> BichonResult<Box<dyn Query>> {
let f = SchemaTools::envelope_fields();
let mut subqueries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
if let Some(authorized_ids) = accounts {
if authorized_ids.is_empty() {
let term = Term::from_field_u64(f.f_account_id, u64::MAX);
subqueries.push((
Occur::Must,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
));
} else {
let mut account_must_queries = Vec::new();
for id in authorized_ids {
let term = Term::from_field_u64(f.f_account_id, id);
account_must_queries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
subqueries.push((
Occur::Must,
Box::new(BooleanQuery::new(account_must_queries)),
));
}
}
if let Some(ref text) = filter.text {
let query = parser
.parse_query(text)
@@ -263,11 +323,9 @@ impl EnvelopeIndexManager {
(f.f_bcc, &filter.bcc),
] {
if let Some(ref v) = opt_value {
let term = Term::from_field_text(field, v);
subqueries.push((
Occur::Must,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
));
if let Ok(query) = RegexQuery::from_pattern(v.as_str(), field) {
subqueries.push((Occur::Must, Box::new(query)));
}
}
}
@@ -284,21 +342,19 @@ impl EnvelopeIndexManager {
}
if let Some(ref name) = filter.attachment_name {
let term = Term::from_field_text(f.f_attachments, name);
subqueries.push((
Occur::Must,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
));
if let Ok(query) = RegexQuery::from_pattern(name.as_str(), f.f_attachments) {
subqueries.push((Occur::Must, Box::new(query)));
}
}
let start_bound = if let Some(from) = filter.since {
Bound::Included(Term::from_field_i64(f.f_internal_date, from))
Bound::Included(Term::from_field_i64(f.f_date, from))
} else {
Bound::Unbounded
};
let end_bound = if let Some(to) = filter.before {
Bound::Included(Term::from_field_i64(f.f_internal_date, to))
Bound::Included(Term::from_field_i64(f.f_date, to))
} else {
Bound::Unbounded
};
@@ -308,20 +364,28 @@ impl EnvelopeIndexManager {
subqueries.push((Occur::Must, Box::new(q)));
}
if let Some(account_id) = filter.account_id {
let term = Term::from_field_u64(f.f_account_id, account_id);
subqueries.push((
Occur::Must,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
));
if let Some(account_ids) = filter.account_ids {
let mut should_queries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
for id in account_ids {
let term = Term::from_field_u64(f.f_account_id, id);
should_queries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
));
}
subqueries.push((Occur::Must, Box::new(BooleanQuery::new(should_queries))));
}
if let Some(mailbox_id) = filter.mailbox_id {
let term = Term::from_field_u64(f.f_mailbox_id, mailbox_id);
subqueries.push((
Occur::Must,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
));
if let Some(mailbox_ids) = filter.mailbox_ids {
let mut should_queries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
for id in mailbox_ids {
let term = Term::from_field_u64(f.f_mailbox_id, id);
should_queries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
));
}
subqueries.push((Occur::Must, Box::new(BooleanQuery::new(should_queries))));
}
let start_bound = if let Some(from) = filter.min_size {
@@ -426,14 +490,16 @@ impl EnvelopeIndexManager {
}
fn collect_facets_recursive(
query: &dyn Query,
searcher: &Searcher,
parent_facet: &str,
all_facets: &mut Vec<TagCount>,
) -> BichonResult<()> {
let mut facet_collector = FacetCollector::for_field(F_TAGS);
facet_collector.add_facet(parent_facet);
let facet_counts = searcher
.search(&AllQuery, &facet_collector)
.search(query, &facet_collector)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
for (facet, count) in facet_counts.get(parent_facet) {
@@ -441,19 +507,82 @@ impl EnvelopeIndexManager {
tag: facet.to_string(),
count,
});
Self::collect_facets_recursive(searcher, &facet.to_string(), all_facets)?;
Self::collect_facets_recursive(query, searcher, &facet.to_string(), all_facets)?;
}
Ok(())
}
pub async fn get_all_tags(&self) -> BichonResult<Vec<TagCount>> {
pub async fn get_all_tags(
&self,
accounts: Option<HashSet<u64>>,
) -> BichonResult<Vec<TagCount>> {
let searcher = self.reader.searcher();
let query: Box<dyn Query> = match accounts {
Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new();
for &id in ids {
let term =
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
subqueries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
Box::new(BooleanQuery::new(subqueries))
}
Some(_) => Box::new(EmptyQuery),
None => Box::new(AllQuery),
};
let mut all_facets = Vec::new();
Self::collect_facets_recursive(&searcher, "/", &mut all_facets)?;
Self::collect_facets_recursive(&query, &searcher, "/", &mut all_facets)?;
Ok(all_facets)
}
pub async fn get_all_contacts(
&self,
accounts: Option<HashSet<u64>>,
) -> BichonResult<HashSet<String>> {
let searcher = self.create_searcher()?;
let query: Box<dyn Query> = match accounts {
Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new();
for &id in ids {
let term =
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
subqueries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
Box::new(BooleanQuery::new(subqueries))
}
Some(_) => Box::new(EmptyQuery),
None => Box::new(AllQuery),
};
let mut contacts_set: HashSet<String> = HashSet::new();
let top_docs = searcher
.search(&query, &TopDocs::with_limit(1_000_000))
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
for (_score, doc_address) in top_docs {
let doc: TantivyDocument = searcher
.doc_async(doc_address)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let contacts = extract_contacts(&doc).await?;
for value in contacts {
contacts_set.insert(value);
}
}
Ok(contacts_set)
}
pub async fn delete_envelopes_multi_account(
&self,
deletes: &HashMap<u64, Vec<u64>>, // HashMap<account_id, envelope_ids>
@@ -550,14 +679,16 @@ impl EnvelopeIndexManager {
pub async fn search(
&self,
accounts: Option<HashSet<u64>>,
filter: SearchFilter,
page: u64,
page_size: u64,
desc: bool,
sort_by: SortBy,
) -> BichonResult<DataPage<Envelope>> {
assert!(page > 0, "Page number must be greater than 0");
assert!(page_size > 0, "Page size must be greater than 0");
let query = self.filter_query(filter, self.query_parser.clone())?;
let query = self.filter_query(accounts, filter, self.query_parser.clone())?;
let searcher = self.create_searcher()?;
let total = searcher
.search(&query, &Count)
@@ -586,17 +717,36 @@ impl EnvelopeIndexManager {
}
let order = if desc { Order::Desc } else { Order::Asc };
let mailbox_docs: Vec<(i64, DocAddress)> = searcher
.search(
&query,
&TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_INTERNAL_DATE, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let mailbox_docs: Vec<DocAddress>;
match sort_by {
SortBy::DATE => {
let date_docs: Vec<(i64, DocAddress)> = searcher
.search(
&query,
&TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_DATE, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
mailbox_docs = date_docs.into_iter().map(|(_, addr)| addr).collect();
}
SortBy::SIZE => {
let size_docs: Vec<(u64, DocAddress)> = searcher
.search(
&query,
&TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_SIZE, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
mailbox_docs = size_docs.into_iter().map(|(_, addr)| addr).collect();
}
}
let mut result = Vec::new();
for (_, doc_address) in mailbox_docs {
for doc_address in mailbox_docs {
let doc: TantivyDocument = searcher
.doc_async(doc_address)
.await
@@ -654,7 +804,7 @@ impl EnvelopeIndexManager {
query.as_ref(),
&TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_INTERNAL_DATE, order),
.order_by_fast_field(F_DATE, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let mut result = Vec::new();
@@ -719,7 +869,7 @@ impl EnvelopeIndexManager {
query.as_ref(),
&TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_INTERNAL_DATE, order),
.order_by_fast_field(F_DATE, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let mut result = Vec::new();
@@ -741,15 +891,76 @@ impl EnvelopeIndexManager {
})
}
pub async fn top_10_largest_emails(&self) -> BichonResult<Vec<LargestEmail>> {
pub async fn get_envelope_by_id(
&self,
account_id: u64,
message_id: u64,
) -> BichonResult<Option<Envelope>> {
let searcher = self.create_searcher()?;
let f = SchemaTools::envelope_fields();
let query = BooleanQuery::new(vec![
(
Occur::Must,
Box::new(TermQuery::new(
Term::from_field_u64(f.f_account_id, account_id),
IndexRecordOption::Basic,
)),
),
(
Occur::Must,
Box::new(TermQuery::new(
Term::from_field_u64(f.f_id, message_id),
IndexRecordOption::Basic,
)),
),
]);
let docs: Vec<(f32, DocAddress)> = searcher
.search(&query, &TopDocs::with_limit(1))
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
if let Some((_, doc_address)) = docs.first() {
let doc: TantivyDocument = searcher
.doc_async(*doc_address)
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let envelope = Envelope::from_tantivy_doc(&doc).await?;
Ok(Some(envelope))
} else {
Ok(None)
}
}
pub async fn top_10_largest_emails(
&self,
accounts: &Option<HashSet<u64>>,
) -> BichonResult<Vec<LargestEmail>> {
self.reader
.reload()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let searcher = self.reader.searcher();
let query: Box<dyn Query> = match accounts {
Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new();
for &id in ids {
let term =
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
subqueries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
Box::new(BooleanQuery::new(subqueries))
}
Some(_) => Box::new(EmptyQuery),
None => Box::new(AllQuery),
};
let mailbox_docs: Vec<(u64, DocAddress)> = searcher
.search(
&AllQuery,
&query,
&TopDocs::with_limit(10).order_by_fast_field(F_SIZE, Order::Desc),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
@@ -905,7 +1116,10 @@ impl EnvelopeIndexManager {
Ok(self.reader.searcher())
}
pub async fn get_dashboard_stats(&self) -> BichonResult<DashboardStats> {
pub async fn get_dashboard_stats(
&self,
accounts: &Option<HashSet<u64>>,
) -> BichonResult<DashboardStats> {
let searcher = self.create_searcher()?;
let now_ms = utc_now!();
let week_ago_ms = (Utc::now() - Duration::from_secs(60 * 60 * 24 * 30)).timestamp_millis();
@@ -916,7 +1130,7 @@ impl EnvelopeIndexManager {
},
"recent_30d_histogram": {
"histogram": {
"field": F_INTERNAL_DATE,
"field": F_DATE,
"interval": 86400000,
"hard_bounds": {
"min": week_ago_ms,
@@ -944,7 +1158,23 @@ impl EnvelopeIndexManager {
}))
.unwrap();
let query = AllQuery;
let query: Box<dyn Query> = match accounts {
Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new();
for &id in ids {
let term =
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
subqueries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
Box::new(BooleanQuery::new(subqueries))
}
Some(_) => Box::new(EmptyQuery),
None => Box::new(AllQuery),
};
let agg_collector = AggregationCollector::from_aggs(aggregations, Default::default());
let agg_results = searcher
.search(&query, &agg_collector)
@@ -1112,7 +1342,17 @@ impl EmlIndexManager {
}
fn open_or_create_index(index_dir: &PathBuf) -> Index {
if !index_dir.exists() {
let need_create = !index_dir.exists()
|| index_dir
.read_dir()
.map(|mut d| d.next().is_none())
.unwrap_or(true);
if need_create {
info!(
"Email data storage not found or empty, creating new mail storage at {}",
index_dir.display()
);
std::fs::create_dir_all(&index_dir).unwrap_or_else(|e| {
panic!("Failed to create index directory {:?}: {}", index_dir, e)
});
@@ -1128,6 +1368,10 @@ impl EmlIndexManager {
.create_in_dir(&index_dir)
.unwrap_or_else(|e| panic!("Failed to create index in {:?}: {}", index_dir, e))
} else {
info!(
"Opening existing email data storage at {}",
index_dir.display()
);
open(&index_dir)
}
}

View File

@@ -16,8 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::logger::file::setup_file_logger;
use crate::modules::logger::file::setup_file_logger;
use crate::modules::settings::cli::SETTINGS;
use chrono::Local;
use std::process;

View File

@@ -0,0 +1,38 @@
use crate::modules::{
cache::imap::mailbox::MailBox,
error::BichonResult,
indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
};
pub async fn delete_mailbox_impl(account_id: u64, mailbox_id: u64) -> BichonResult<()> {
let mailbox = MailBox::get(mailbox_id).await?;
let name = mailbox.name;
let delimiter = mailbox.delimiter.unwrap_or("/".to_owned());
let all_mailboxes = MailBox::list_all(account_id).await?;
let prefix = format!("{}{}", name, delimiter);
let ids_to_delete: Vec<u64> = all_mailboxes
.into_iter()
.filter(|m| m.id == mailbox_id || m.name.starts_with(&prefix))
.map(|m| m.id)
.collect();
if ids_to_delete.is_empty() {
return Ok(());
}
for id in &ids_to_delete {
MailBox::delete(*id).await?;
}
ENVELOPE_INDEX_MANAGER
.delete_mailbox_envelopes(account_id, ids_to_delete.clone())
.await?;
EML_INDEX_MANAGER
.delete_mailbox_envelopes(account_id, ids_to_delete)
.await?;
Ok(())
}

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::migration::{AccountModel, AccountType};
use crate::modules::cache::imap::mailbox::{Attribute, AttributeEnum, MailBox};
use crate::modules::context::executors::MAIL_CONTEXT;
@@ -27,7 +26,7 @@ use crate::raise_error;
use async_imap::types::Name;
pub async fn get_account_mailboxes(account_id: u64, remote: bool) -> BichonResult<Vec<MailBox>> {
let account = AccountModel::check_account_active(account_id).await?;
let account = AccountModel::check_account_exists(account_id).await?;
if remote {
if matches!(account.account_type, AccountType::IMAP) {
request_imap_all_mailbox_list(account_id).await
@@ -64,8 +63,15 @@ pub async fn convert_names_to_mailboxes(
for name in names.into_iter() {
// Convert the name into a MailBox structure
let mailbox_name = name.name().to_string();
let mut mailbox: MailBox = name.into();
tracing::debug!(
raw = &mailbox_name,
decoded = &mailbox.name,
"mailbox name comparison"
);
if contains_no_select(&mailbox.attributes) {
continue;
}

View File

@@ -16,5 +16,5 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod delete;
pub mod list;

View File

@@ -0,0 +1,104 @@
use crate::{
encode_mailbox_name,
modules::{
account::migration::{AccountModel, AccountType},
context::executors::MAIL_CONTEXT,
error::{code::ErrorCode, BichonResult},
indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
},
raise_error,
};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
const MAX_RESTORE_COUNT: usize = 100;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct RestoreMessagesRequest {
/// Message IDs to restore (max 100)
pub message_ids: Vec<u64>,
}
pub async fn restore_emails(account_id: u64, message_ids: Vec<u64>) -> BichonResult<()> {
if message_ids.len() > MAX_RESTORE_COUNT {
return Err(raise_error!(
format!(
"Too many messages to restore: {} (max {})",
message_ids.len(),
MAX_RESTORE_COUNT
),
ErrorCode::InvalidParameter
));
}
let account = AccountModel::check_account_exists(account_id).await?;
if !matches!(account.account_type, AccountType::IMAP) {
return Err(raise_error!(
"Account type is not IMAP".into(),
ErrorCode::Incompatible
));
}
let executor = MAIL_CONTEXT.imap(account.id).await?;
let mut failed = Vec::new();
for message_id in message_ids {
let result: BichonResult<()> = async {
let envelope = ENVELOPE_INDEX_MANAGER
.get_envelope_by_id(account_id, message_id)
.await?
.ok_or_else(|| {
raise_error!(
format!(
"Envelope not found: account_id={} message_id={}",
account_id, message_id
),
ErrorCode::ResourceNotFound
)
})?;
let eml = EML_INDEX_MANAGER
.get(account_id, message_id)
.await?
.ok_or_else(|| {
raise_error!(
format!(
"Email record not found: account_id={} id={}",
account_id, message_id
),
ErrorCode::ResourceNotFound
)
})?;
if let Some(mailbox_name) = envelope.mailbox_name {
executor
.append(encode_mailbox_name!(&mailbox_name), None, None, &eml)
.await?;
}
Ok(())
}
.await;
if let Err(err) = result {
failed.push(message_id);
tracing::warn!(
account_id = account_id,
message_id = message_id,
error = ?err,
"Failed to restore email"
);
}
}
if !failed.is_empty() {
tracing::info!(
account_id = account_id,
failed_count = failed.len(),
failed_message_ids = ?failed,
"Restore emails finished with partial failures"
);
}
Ok(())
}

View File

@@ -0,0 +1,10 @@
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct Contact {
pub email: String,
pub name: Option<String>,
}

View File

@@ -68,7 +68,7 @@ pub async fn retrieve_email_content(
account_id: u64,
id: u64,
) -> BichonResult<FullMessageContent> {
AccountModel::check_account_active(account_id).await?;
AccountModel::check_account_exists(account_id).await?;
let eml = EML_INDEX_MANAGER
.get(account_id, id)
.await?

View File

@@ -33,7 +33,7 @@ pub async fn list_messages_impl(
page: u64,
page_size: u64,
) -> BichonResult<DataPage<Envelope>> {
AccountModel::check_account_active(account_id).await?;
AccountModel::check_account_exists(account_id).await?;
validate_pagination_params(page, page_size)?;
ENVELOPE_INDEX_MANAGER
.list_mailbox_envelopes(account_id, mailbox_id, page, page_size, true)
@@ -62,7 +62,7 @@ pub async fn get_thread_messages(
page: u64,
page_size: u64,
) -> BichonResult<DataPage<Envelope>> {
AccountModel::check_account_active(account_id).await?;
AccountModel::check_account_exists(account_id).await?;
ENVELOPE_INDEX_MANAGER
.list_thread_envelopes(account_id, thread_id, page, page_size, true)
.await

View File

@@ -16,7 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod append;
pub mod contacts;
pub mod content;
pub mod delete;
pub mod list;

View File

@@ -16,8 +16,9 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::HashSet;
use poem_openapi::Object;
use poem_openapi::{Enum, Object};
use serde::{Deserialize, Serialize};
use crate::{
@@ -38,8 +39,8 @@ pub struct SearchFilter {
pub bcc: Option<String>,
pub since: Option<i64>,
pub before: Option<i64>,
pub account_id: Option<u64>,
pub mailbox_id: Option<u64>,
pub account_ids: Option<Vec<u64>>,
pub mailbox_ids: Option<Vec<u64>>,
pub min_size: Option<u64>,
pub max_size: Option<u64>,
pub message_id: Option<String>,
@@ -48,11 +49,20 @@ pub struct SearchFilter {
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Enum)]
pub enum SortBy {
#[default]
DATE,
SIZE,
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct SearchRequest {
filter: SearchFilter,
page: u64,
page_size: u64,
sort_by: Option<SortBy>,
desc: Option<bool>,
}
impl SearchRequest {
pub fn validate(&self) -> BichonResult<()> {
@@ -72,9 +82,19 @@ impl SearchRequest {
}
}
pub async fn search_messages_impl(request: SearchRequest) -> BichonResult<DataPage<Envelope>> {
pub async fn search_messages_impl(
accounts: Option<HashSet<u64>>,
request: SearchRequest,
) -> BichonResult<DataPage<Envelope>> {
request.validate()?;
ENVELOPE_INDEX_MANAGER
.search(request.filter, request.page, request.page_size, true)
.search(
accounts,
request.filter,
request.page,
request.page_size,
request.desc.unwrap_or(true),
request.sort_by.unwrap_or(SortBy::DATE),
)
.await
}

View File

@@ -16,10 +16,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
pub mod account;
pub mod autoconfig;
pub mod cache;
pub mod cli;
pub mod common;
pub mod context;
pub mod dashboard;
@@ -27,6 +27,7 @@ pub mod database;
pub mod envelope;
pub mod error;
pub mod imap;
pub mod import;
pub mod indexer;
pub mod logger;
pub mod mailbox;
@@ -36,5 +37,6 @@ pub mod rest;
pub mod settings;
pub mod tasks;
pub mod token;
pub mod users;
pub mod utils;
pub mod version;

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::{
encrypt, id,
modules::{
@@ -97,6 +96,27 @@ impl OAuth2 {
})
}
pub fn scrub_sensitive_fields(&mut self) {
let mask = "********";
let notice =
" [REDACTED: You do not have permission to view sensitive configuration details]";
let original_desc = self
.description
.clone()
.unwrap_or_else(|| "OAuth2 Config".to_string());
self.description = Some(format!("{}{}", original_desc, notice));
self.client_id = mask.to_string();
self.client_secret = mask.to_string();
self.auth_url = mask.to_string();
self.token_url = mask.to_string();
self.redirect_uri = mask.to_string();
self.scopes = None;
self.extra_params = None;
}
pub async fn save(&self) -> BichonResult<()> {
insert_impl(DB_MANAGER.meta_db(), self.to_owned()).await?;
Ok(())

View File

@@ -16,16 +16,12 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::common::auth::ClientContext;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::token::payload::AccessTokenUpdateRequest;
use crate::modules::token::root::set_root_password;
use crate::modules::{
token::payload::AccessTokenCreateRequest,
token::{root::reset_root_token, AccessToken},
};
use crate::modules::token::view::AccessTokenResp;
use crate::modules::users::permissions::Permission;
use crate::modules::{token::payload::AccessTokenCreateRequest, token::AccessTokenModel};
use poem_openapi::payload::PlainText;
use poem_openapi::{param::Path, payload::Json, OpenApi};
@@ -33,9 +29,6 @@ pub struct AccessTokenApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::AccessToken")]
impl AccessTokenApi {
/// Lists all access tokens in the system.
///
/// Requires root privileges.
#[oai(
path = "/access-token-list",
method = "get",
@@ -44,31 +37,15 @@ impl AccessTokenApi {
async fn list_access_tokens(
&self,
context: ClientContext,
) -> ApiResult<Json<Vec<AccessToken>>> {
context.require_root()?;
Ok(Json(AccessToken::list_all().await?))
) -> ApiResult<Json<Vec<AccessTokenResp>>> {
context
.require_permission(None, Permission::TOKEN_MANAGE)
.await?;
Ok(Json(AccessTokenModel::list_all_api_tokens().await?))
}
/// Lists access tokens for a specific account.
///
/// Requires root privileges.
#[oai(
path = "/access-token-list/:account_id",
method = "get",
operation_id = "list_account_access_tokens"
)]
async fn list_account_access_tokens(
&self,
/// The ID of the account whose tokens are to be retrieved.
account_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Vec<AccessToken>>> {
context.require_root()?;
Ok(Json(AccessToken::list_account_tokens(account_id.0).await?))
}
/// Deletes a specific access token.
///
/// Requires root privileges.
#[oai(
path = "/access-token/:token",
method = "delete",
@@ -80,13 +57,18 @@ impl AccessTokenApi {
token: Path<String>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(AccessToken::delete(token.0.trim()).await?)
let token = token.0.trim();
let token = AccessTokenModel::get_token(token).await?;
if context.user.id != token.user_id {
context
.require_permission(None, Permission::TOKEN_MANAGE)
.await?;
}
Ok(AccessTokenModel::delete(&token.token).await?)
}
/// Creates a new access token.
///
/// Requires root privileges.
/// Creates a new api token.
#[oai(
path = "/access-token",
method = "post",
@@ -98,59 +80,15 @@ impl AccessTokenApi {
/// The request payload
payload: Json<AccessTokenCreateRequest>,
) -> ApiResult<PlainText<String>> {
context.require_root()?;
Ok(PlainText(AccessToken::create(payload.0).await?))
}
let current_user_id = context.user.id;
let target_user_id = payload.0.user_id.unwrap_or(current_user_id);
if target_user_id != current_user_id {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
}
/// Updates an existing access token.
///
/// Requires root privileges.
#[oai(
path = "/access-token/:token",
method = "post",
operation_id = "update_access_token"
)]
async fn update_access_token(
&self,
context: ClientContext,
/// The access token to be updated.
token: Path<String>,
/// The request payload.
payload: Json<AccessTokenUpdateRequest>,
) -> ApiResult<()> {
context.require_root()?;
Ok(AccessToken::update(token.0.trim(), payload.0).await?)
}
/// Regenerates the root access token.
///
/// Requires root privileges.
#[oai(
path = "/reset-root-token",
method = "post",
operation_id = "regenerate_root_token"
)]
async fn regenerate_root_token(&self, context: ClientContext) -> ApiResult<PlainText<String>> {
context.require_root()?;
Ok(PlainText(reset_root_token().await?))
}
/// Reset the Root user's password.
///
/// Only callable by an already authenticated Root user.
/// This endpoint updates the Root password to `password_str`
/// and regenerates the `root_token`, invalidating any previous token.
#[oai(
path = "/reset-root-password",
method = "post",
operation_id = "reset_root_password"
)]
async fn reset_root_password(
&self,
password_str: PlainText<String>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(set_root_password(password_str.0.trim()).await?)
let token_string = AccessTokenModel::create_api_token(target_user_id, payload.0).await?;
Ok(PlainText(token_string))
}
}

View File

@@ -16,24 +16,25 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::{HashMap, HashSet};
use std::collections::BTreeSet;
use crate::modules::account::grant::BatchAccountRoleRequest;
use crate::modules::account::migration::AccountModel;
use crate::modules::account::payload::{
filter_accessible_accounts, AccountCreateRequest, AccountUpdateRequest, MinimalAccount,
};
use crate::modules::account::state::AccountRunningState;
use crate::modules::account::migration::AccountModel;
use crate::modules::account::view::AccountResp;
use crate::modules::common::auth::ClientContext;
use crate::modules::common::paginated::paginate_vec;
use crate::modules::error::code::ErrorCode;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::response::DataPage;
use crate::modules::rest::ApiResult;
use crate::modules::token::{AccessToken, AccountInfo};
use crate::modules::users::permissions::Permission;
use crate::modules::users::UserModel;
use crate::raise_error;
use poem::web::Path;
use poem_openapi::param::Query;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
@@ -54,7 +55,9 @@ impl AccountApi {
context: ClientContext,
) -> ApiResult<Json<AccountModel>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?;
Ok(Json(AccountModel::get(account_id).await?))
}
@@ -71,7 +74,9 @@ impl AccountApi {
context: ClientContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
Ok(AccountModel::delete(account_id).await?)
}
@@ -83,14 +88,10 @@ impl AccountApi {
payload: Json<AccountCreateRequest>,
context: ClientContext,
) -> ApiResult<Json<AccountModel>> {
let account = AccountModel::create_account(payload.0).await?;
if let Some(access_token) = &context.access_token {
let account_info = AccountInfo {
id: account.id,
email: account.email.clone(),
};
AccessToken::grant_account_access(&access_token.token, account_info).await?;
}
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
let account = AccountModel::create_account(context.user.id, payload.0).await?;
Ok(Json(account))
}
@@ -109,16 +110,14 @@ impl AccountApi {
context: ClientContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
Ok(AccountModel::update(account_id, payload.0, true).await?)
}
/// List accounts with optional pagination parameters
#[oai(
path = "/accounts",
method = "get",
operation_id = "list_accounts"
)]
#[oai(path = "/accounts", method = "get", operation_id = "list_accounts")]
async fn list_accounts(
&self,
/// Optional. The page number to retrieve (starting from 1).
@@ -128,35 +127,61 @@ impl AccountApi {
/// Optional. Whether to sort the list in descending order.
desc: Query<Option<bool>>,
context: ClientContext,
) -> ApiResult<Json<DataPage<AccountModel>>> {
let accessible_accounts = context.accessible_accounts()?;
) -> ApiResult<Json<DataPage<AccountResp>>> {
let is_admin = context.user.is_admin().await;
let sort_desc = desc.0.unwrap_or(true);
if accessible_accounts.is_none() {
return Ok(Json(
AccountModel::paginate_list(page.0, page_size.0, desc.0).await?,
));
}
let all_accounts = AccountModel::list_all().await?;
let allowed_ids: BTreeSet<u64> =
accessible_accounts.unwrap().iter().map(|a| a.id).collect();
let mut filtered_accounts: Vec<AccountModel> = all_accounts
let user_map: HashMap<u64, UserModel> = UserModel::list_all()
.await?
.into_iter()
.filter(|acct| allowed_ids.contains(&acct.id))
.map(|u| (u.id, u))
.collect();
let page_data: DataPage<AccountModel> = if is_admin {
AccountModel::paginate_list(page.0, page_size.0, desc.0).await?
} else {
let authorized_ids: HashSet<u64> =
context.user.account_access_map.keys().cloned().collect();
if authorized_ids.is_empty() {
return Ok(Json(DataPage {
current_page: page.0,
page_size: page_size.0,
total_items: 0,
items: vec![],
total_pages: Some(0),
}));
}
let mut accounts: Vec<AccountModel> = AccountModel::list_all()
.await?
.into_iter()
.filter(|acct| authorized_ids.contains(&acct.id))
.collect();
accounts.sort_by(|a, b| {
if sort_desc {
b.created_at.cmp(&a.created_at)
} else {
a.created_at.cmp(&b.created_at)
}
});
paginate_vec(&accounts, page.0, page_size.0).map(DataPage::from)?
};
let items = page_data
.items
.into_iter()
.map(|account| AccountResp::from_model(account, &user_map))
.collect();
let sort_desc = desc.0.unwrap_or(true);
filtered_accounts.sort_by(|a, b| {
if sort_desc {
b.created_at.cmp(&a.created_at)
} else {
a.created_at.cmp(&b.created_at)
}
});
let page_data =
paginate_vec(&filtered_accounts, page.0, page_size.0).map(DataPage::from)?;
Ok(Json(page_data))
Ok(Json(DataPage {
current_page: page_data.current_page,
page_size: page_data.page_size,
total_items: page_data.total_items,
total_pages: page_data.total_pages,
items,
}))
}
/// Get the running state of an account
@@ -172,7 +197,10 @@ impl AccountApi {
context: ClientContext,
) -> ApiResult<Json<AccountRunningState>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
AccountModel::check_account_exists(account_id).await?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?;
let state = AccountRunningState::get(account_id).await?.ok_or_else(|| {
raise_error!(
"account running state is not found".into(),
@@ -193,15 +221,30 @@ impl AccountApi {
)]
async fn minimal_accounts_list(
&self,
only_nosync: Query<Option<bool>>,
context: ClientContext,
) -> ApiResult<Json<Vec<MinimalAccount>>> {
let accessible_accounts = context.accessible_accounts()?;
let is_admin = context.user.is_admin().await;
let only_nosync = only_nosync.0.unwrap_or_default();
let minimal_list = AccountModel::minimal_list().await?;
let result = match accessible_accounts {
Some(set) => filter_accessible_accounts(&minimal_list, set),
None => minimal_list,
};
let minimal_list = AccountModel::minimal_list(only_nosync).await?;
if is_admin {
return Ok(Json(minimal_list));
}
let authorized_ids: Vec<u64> = context.user.account_access_map.keys().cloned().collect();
let result = filter_accessible_accounts(&minimal_list, &authorized_ids);
Ok(Json(result))
}
#[oai(path = "/accounts/access/assignments", method = "post")]
async fn batch_assign_account_role(
&self,
req: Json<BatchAccountRoleRequest>,
context: ClientContext,
) -> ApiResult<()> {
req.validate_existence().await?;
req.0.do_assign(&context).await?;
Ok(())
}
}

View File

@@ -16,14 +16,15 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::autoconfig::entity::MailServerConfig;
use crate::modules::autoconfig::load::resolve_autoconfig;
use crate::modules::common::auth::ClientContext;
use crate::modules::error::code::ErrorCode;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::users::permissions::Permission;
use crate::raise_error;
use poem::web::Path;
use poem_openapi::param::Path;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
@@ -40,8 +41,13 @@ impl AutoConfigApi {
async fn autoconfig(
&self,
/// The email address to lookup configuration for
email_address: Path<String>
email_address: Path<String>,
context: ClientContext,
) -> ApiResult<Json<MailServerConfig>> {
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
let result = resolve_autoconfig(email_address.0.trim())
.await?
.ok_or_else(|| {

View File

@@ -0,0 +1,52 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::common::auth::ClientContext;
use crate::modules::import::BatchEmlResult;
use crate::modules::import::{BatchEmlRequest, ImportEmls};
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::users::permissions::Permission;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
pub struct ImportApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Import")]
impl ImportApi {
/// Batch import one or more EML files into a specified account and mail folder.
///
/// This endpoint accepts a JSON payload containing:
/// - `account_id`: the target account to import emails into
/// - `mail_folder`: the mailbox/folder name
/// - `emls`: a list of base64-encoded .eml files
///
/// Returns a summary of the import result, including total processed, successful, and failed emails.
#[oai(path = "/import", method = "post", operation_id = "do_batch_import")]
async fn do_batch_import(
&self,
/// JSON payload with account info and EML files to import
payload: Json<BatchEmlRequest>,
context: ClientContext,
) -> ApiResult<Json<BatchEmlResult>> {
context
.require_permission(Some(payload.0.account_id), Permission::DATA_IMPORT_BATCH)
.await?;
Ok(Json(ImportEmls::do_import(payload.0).await?))
}
}

View File

@@ -16,14 +16,14 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::cache::imap::mailbox::MailBox;
use crate::modules::common::auth::ClientContext;
use crate::modules::mailbox::delete::delete_mailbox_impl;
use crate::modules::mailbox::list::get_account_mailboxes;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use poem::web::Path;
use poem_openapi::param::Query;
use crate::modules::users::permissions::Permission;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
@@ -53,8 +53,38 @@ impl MailBoxApi {
context: ClientContext,
) -> ApiResult<Json<Vec<MailBox>>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?;
let remote = remote.0.unwrap_or(false);
Ok(Json(get_account_mailboxes(account_id, remote).await?))
}
/// Deletes a mailbox for the specified account.
///
/// Requires `DATA_DELETE` permission on the target account.
///
/// # Parameters
/// - `account_id`: Account identifier.
/// - `mailbox_id`: Mailbox identifier.
///
#[oai(
path = "/delete-mailbox/:account_id/:mailbox_id",
method = "delete",
operation_id = "delete_mailbox"
)]
async fn delete_mailbox(
&self,
/// The unique identifier of the account.
account_id: Path<u64>,
mailbox_id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
let account_id = account_id.0;
let mailbox_id = mailbox_id.0;
context
.require_permission(Some(account_id), Permission::DATA_DELETE)
.await?;
Ok(delete_mailbox_impl(account_id, mailbox_id).await?)
}
}

View File

@@ -16,11 +16,13 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::migration::AccountModel;
use crate::modules::common::auth::ClientContext;
use crate::modules::indexer::envelope::Envelope;
use crate::modules::indexer::manager::EML_INDEX_MANAGER;
use crate::modules::indexer::manager::ENVELOPE_INDEX_MANAGER;
use crate::modules::message::append::restore_emails;
use crate::modules::message::append::RestoreMessagesRequest;
use crate::modules::message::content::{retrieve_email_content, FullMessageContent};
use crate::modules::message::delete::delete_messages_impl;
use crate::modules::message::list::{get_thread_messages, list_messages_impl};
@@ -31,13 +33,14 @@ use crate::modules::rest::api::ApiTags;
use crate::modules::rest::response::DataPage;
use crate::modules::rest::ApiResult;
use crate::modules::rest::ErrorCode;
use crate::modules::users::permissions::Permission;
use crate::raise_error;
use poem::web::Path;
use poem::Body;
use poem_openapi::param::Query;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::{Attachment, AttachmentType, Json};
use poem_openapi::OpenApi;
use std::collections::HashMap;
use std::collections::HashSet;
use tantivy::schema::Facet;
pub struct MessageApi;
@@ -58,12 +61,14 @@ impl MessageApi {
) -> ApiResult<()> {
let request = payload.0;
for account_id in request.keys() {
context.require_account_access(*account_id)?;
context
.require_permission(Some(*account_id), Permission::DATA_DELETE)
.await?;
}
Ok(delete_messages_impl(request).await?)
}
/// Lists messages in a specified mailbox for the given account.
/// Lists messages in a mailbox. Requires `mailbox_id`, `page`, and `page_size` query parameters.
#[oai(
path = "/list-messages/:account_id",
method = "get",
@@ -71,7 +76,9 @@ impl MessageApi {
)]
async fn list_messages(
&self,
/// The ID of the account.
account_id: Path<u64>,
/// The ID of the mailbox to list messages from.
mailbox_id: Query<u64>,
page: Query<u64>,
page_size: Query<u64>,
@@ -79,13 +86,16 @@ impl MessageApi {
) -> ApiResult<Json<DataPage<Envelope>>> {
let account_id = account_id.0;
let mailbox_id = mailbox_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
Ok(Json(
list_messages_impl(account_id, mailbox_id, page.0, page_size.0).await?,
))
}
/// Lists messages in a specified mailbox for the given account.
/// Searches messages across all mailboxes using various filter criteria.
/// The search filters are provided in the request body.
#[oai(
path = "/search-messages",
method = "post",
@@ -96,11 +106,18 @@ impl MessageApi {
payload: Json<SearchRequest>,
context: ClientContext,
) -> ApiResult<Json<DataPage<Envelope>>> {
context.require_root()?;
Ok(Json(search_messages_impl(payload.0).await?))
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(search_messages_impl(authorized_ids, payload.0).await?))
}
/// Get thread's envelopes in a specified mailbox for the given account.
/// Retrieves all messages belonging to a specific thread. Requires `thread_id`, `page`, and `page_size` query parameters.
#[oai(
path = "/get-thread-messages/:account_id",
method = "get",
@@ -120,71 +137,141 @@ impl MessageApi {
) -> ApiResult<Json<DataPage<Envelope>>> {
let account_id = account_id.0;
let thread_id = thread_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
Ok(Json(
get_thread_messages(account_id, thread_id, page.0, page_size.0).await?,
))
}
/// Fetches the content of a specific email for the given account.
/// Fetches the content of a specific email.
#[oai(
path = "/message-content/:account_id",
path = "/message-content/:account_id/:message_id",
method = "get",
operation_id = "fetch_message_content"
)]
async fn fetch_message_content(
&self,
/// The ID of the account.
account_id: Path<u64>,
id: Query<u64>,
/// The ID of the message to fetch.
message_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<FullMessageContent>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
Ok(Json(retrieve_email_content(account_id, id.0).await?))
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
Ok(Json(
retrieve_email_content(account_id, message_id.0).await?,
))
}
/// Fetches the full content of a specific email for the given account.
/// Retrieves the envelope (metadata) of a specific message.
#[oai(
path = "/download-message/:account_id",
path = "/envelope/:account_id/:message_id",
method = "get",
operation_id = "get_envelope"
)]
async fn get_envelope(
&self,
/// The ID of the account.
account_id: Path<u64>,
/// The ID of the message.
message_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Envelope>> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
let envelope = ENVELOPE_INDEX_MANAGER
.get_envelope_by_id(account_id, message_id.0)
.await?
.ok_or_else(|| {
raise_error!(
format!(
"Envelope not found: account_id={} message_id={}",
account_id, message_id.0
),
ErrorCode::ResourceNotFound
)
})?;
Ok(Json(envelope))
}
/// Downloads the raw EML file of a specific email.
#[oai(
path = "/download-message/:account_id/:message_id",
method = "get",
operation_id = "download_message"
)]
async fn download_message(
&self,
/// The ID of the account.
account_id: Path<u64>,
id: Query<u64>,
/// The ID of the message to download.
message_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
let id = id.0;
let reader = EML_INDEX_MANAGER.get_reader(account_id, id).await?;
AccountModel::check_account_exists(account_id).await?;
context
.require_permission(Some(account_id), Permission::DATA_RAW_DOWNLOAD)
.await?;
let message_id = message_id.0;
let reader = EML_INDEX_MANAGER.get_reader(account_id, message_id).await?;
let body = Body::from_async_read(reader);
let attachment = Attachment::new(body)
.attachment_type(AttachmentType::Attachment)
.filename(format!("{id}.eml"));
.filename(format!("{message_id}.eml"));
Ok(attachment)
}
/// Downloads a specific attachment by filename.
#[oai(
path = "/download-attachment/:account_id",
path = "/restore-messages/:account_id",
method = "post",
operation_id = "restore_messages"
)]
async fn restore_messages(
&self,
account_id: Path<u64>,
/// Message IDs to restore.
payload: Json<RestoreMessagesRequest>,
context: ClientContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context
.require_permission(Some(account_id), Permission::DATA_EXPORT_BATCH)
.await?;
Ok(restore_emails(account_id, payload.0.message_ids).await?)
}
/// Downloads a specific attachment from an email. Requires `name` query parameter.
#[oai(
path = "/download-attachment/:account_id/:message_id",
method = "get",
operation_id = "download_attachment"
)]
async fn download_attachment(
&self,
/// The ID of the account.
account_id: Path<u64>,
id: Query<u64>,
/// The ID of the message containing the attachment.
message_id: Path<u64>,
/// The filename of the attachment to download.
name: Query<String>,
context: ClientContext,
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
let email_id = id.0;
AccountModel::check_account_exists(account_id).await?;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
let name = name.0.trim();
let reader = EML_INDEX_MANAGER
.get_attachment(account_id, email_id, name)
.get_attachment(account_id, message_id.0, name)
.await?;
let body = Body::from_async_read(reader);
let attachment = Attachment::new(body)
@@ -194,8 +281,18 @@ impl MessageApi {
}
/// Returns all facets in the index along with their document counts.
#[oai(path = "/all-tags", method = "get", operation_id = "get_all_tags")]
async fn get_all_tags(&self) -> ApiResult<Json<Vec<TagCount>>> {
Ok(Json(ENVELOPE_INDEX_MANAGER.get_all_tags().await?))
async fn get_all_tags(&self, context: ClientContext) -> ApiResult<Json<Vec<TagCount>>> {
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(
ENVELOPE_INDEX_MANAGER.get_all_tags(authorized_ids).await?,
))
}
/// Adds or removes facet tags for multiple emails across accounts.
@@ -204,15 +301,47 @@ impl MessageApi {
method = "post",
operation_id = "update_envelope_tags"
)]
async fn update_envelope_tags(&self, req: Json<UpdateTagsRequest>) -> ApiResult<()> {
async fn update_envelope_tags(
&self,
req: Json<UpdateTagsRequest>,
context: ClientContext,
) -> ApiResult<()> {
let req = req.0;
for tag in &req.tags {
Facet::from_text(tag)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
}
for account_id in req.updates.keys() {
context
.require_permission(Some(*account_id), Permission::DATA_MANAGE)
.await?;
}
ENVELOPE_INDEX_MANAGER
.update_envelope_tags(req.updates, req.tags)
.await?;
Ok(())
}
#[oai(
path = "/all-contacts",
method = "get",
operation_id = "get_all_contacts"
)]
async fn get_all_contacts(&self, context: ClientContext) -> ApiResult<Json<HashSet<String>>> {
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(
ENVELOPE_INDEX_MANAGER
.get_all_contacts(authorized_ids)
.await?,
))
}
}

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use access_token::AccessTokenApi;
use account::AccountApi;
use auto_config::AutoConfigApi;
@@ -26,15 +25,20 @@ use oauth2::OAuth2Api;
use poem_openapi::{OpenApiService, Tags};
use system::SystemApi;
use crate::bichon_version;
use crate::{
bichon_version,
modules::rest::api::{import::ImportApi, users::UsersApi},
};
pub mod access_token;
pub mod account;
pub mod auto_config;
pub mod import;
pub mod mailbox;
pub mod message;
pub mod oauth2;
pub mod system;
pub mod users;
#[derive(Tags)]
pub enum ApiTags {
@@ -45,6 +49,8 @@ pub enum ApiTags {
OAuth2,
Message,
System,
Import,
Users,
}
type RustMailOpenApi = (
@@ -55,6 +61,8 @@ type RustMailOpenApi = (
MailBoxApi,
OAuth2Api,
MessageApi,
ImportApi,
UsersApi,
);
pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
@@ -67,6 +75,8 @@ pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
MailBoxApi,
OAuth2Api,
MessageApi,
ImportApi,
UsersApi,
),
"BichonApi",
bichon_version!(),

View File

@@ -16,7 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::account::migration::AccountModel;
use crate::modules::common::auth::ClientContext;
use crate::modules::error::code::ErrorCode;
use crate::modules::oauth2::entity::{OAuth2, OAuth2CreateRequest, OAuth2UpdateRequest};
@@ -25,9 +25,9 @@ use crate::modules::oauth2::token::{ExternalOAuth2Request, OAuth2AccessToken};
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::response::DataPage;
use crate::modules::rest::ApiResult;
use crate::modules::users::permissions::Permission;
use crate::raise_error;
use poem::web::Path;
use poem_openapi::param::Query;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::{Json, PlainText};
use poem_openapi::OpenApi;
@@ -50,14 +50,26 @@ impl OAuth2Api {
id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<OAuth2>> {
context.require_root()?;
let id = id.0;
Ok(Json(OAuth2::get(id).await?.ok_or_else(|| {
let mut oauth2 = OAuth2::get(id).await?.ok_or_else(|| {
raise_error!(
format!("OAuth2 configuration id='{id}' not found"),
ErrorCode::ResourceNotFound
)
})?))
})?;
if context
.has_permission(None, Permission::ROOT)
.await
{
return Ok(Json(oauth2));
}
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
oauth2.scrub_sensitive_fields();
Ok(Json(oauth2))
}
/// Deletes an OAuth2 configuration by name.
@@ -75,7 +87,9 @@ impl OAuth2Api {
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(OAuth2::delete(id.0).await?)
}
@@ -94,7 +108,9 @@ impl OAuth2Api {
request: Json<OAuth2CreateRequest>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
let entity = OAuth2::new(request.0)?;
Ok(entity.save().await?)
}
@@ -116,7 +132,9 @@ impl OAuth2Api {
payload: Json<OAuth2UpdateRequest>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(OAuth2::update(id.0, payload.0).await?)
}
@@ -139,10 +157,23 @@ impl OAuth2Api {
desc: Query<Option<bool>>,
context: ClientContext,
) -> ApiResult<Json<DataPage<OAuth2>>> {
context.require_root()?;
Ok(Json(
OAuth2::paginate_list(page.0, page_size.0, desc.0).await?,
))
let mut list = OAuth2::paginate_list(page.0, page_size.0, desc.0).await?;
if context
.has_permission(None, Permission::ROOT)
.await
{
return Ok(Json(list));
}
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
for item in &mut list.items {
item.scrub_sensitive_fields();
}
Ok(Json(list))
}
/// Generates an OAuth2 authorization URL for a specific account.
@@ -160,8 +191,14 @@ impl OAuth2Api {
request: Json<AuthorizeUrlRequest>,
context: ClientContext,
) -> ApiResult<PlainText<String>> {
context.require_root()?;
let request = request.0;
context
.require_any_permission(vec![
(None, Permission::ACCOUNT_CREATE),
(Some(request.account_id), Permission::ACCOUNT_MANAGE),
])
.await?;
let flow = OAuth2Flow::new(request.oauth2_id);
Ok(PlainText(flow.authorize_url(request.account_id).await?))
}
@@ -181,7 +218,9 @@ impl OAuth2Api {
context: ClientContext,
) -> ApiResult<Json<OAuth2AccessToken>> {
let account = account_id.0;
context.require_account_access(account)?;
context
.require_permission(Some(account), Permission::ACCOUNT_MANAGE)
.await?;
Ok(Json(OAuth2AccessToken::get(account).await?.ok_or_else(
|| {
raise_error!(
@@ -195,19 +234,19 @@ impl OAuth2Api {
/// Configures an external OAuth2 token for a specified account.
///
/// This endpoint allows two usage modes:
/// 1. If only an `access_token` is provided, RustMailer will store it directly.
/// - In this mode, RustMailer **cannot refresh** the token, since it has no
/// 1. If only an `access_token` is provided, Bichon will store it directly.
/// - In this mode, Bichon **cannot refresh** the token, since it has no
/// associated OAuth2 configuration or refresh token.
/// - The caller is responsible for periodically updating the access token
/// by calling this endpoint again.
/// 2. If both `oauth2_id` and `refresh_token` are provided, it means the external
/// OAuth2 authorization flow has been completed outside RustMailer.
/// OAuth2 authorization flow has been completed outside Bichon.
/// - Since the OAuth2 configuration (including client_id and client_secret)
/// is already stored in RustMailer, the service can use the refresh token
/// is already stored in Bichon, the service can use the refresh token
/// to obtain new access tokens automatically.
///
/// Note: The `oauth2_id` must reference a valid OAuth2 configuration
/// already created in RustMailer.
/// already created in Bichon.
#[oai(
path = "/store-external-oauth2-token/:account_id",
method = "post",
@@ -219,10 +258,13 @@ impl OAuth2Api {
request: Json<ExternalOAuth2Request>,
context: ClientContext,
) -> ApiResult<()> {
let account = account_id.0;
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
// Check account access permissions
context.require_account_access(account)?;
OAuth2AccessToken::upsert_external_oauth_token(account, request.0).await?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
OAuth2AccessToken::upsert_external_oauth_token(account_id, request.0).await?;
Ok(())
}
}

View File

@@ -16,13 +16,15 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::common::auth::ClientContext;
use crate::modules::dashboard::DashboardStats;
use crate::modules::error::code::ErrorCode;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::settings::cli::SETTINGS;
use crate::modules::settings::proxy::Proxy;
use crate::modules::settings::SystemConfigurations;
use crate::modules::users::permissions::Permission;
use crate::modules::version::{fetch_notifications, Notifications};
use crate::raise_error;
use poem_openapi::param::Path;
@@ -60,14 +62,20 @@ impl SystemApi {
path = "/dashboard-stats",
operation_id = "get_dashboard_stats"
)]
async fn get_dashboard_stats(&self) -> ApiResult<Json<DashboardStats>> {
let stats = DashboardStats::get().await?;
async fn get_dashboard_stats(&self, context: ClientContext) -> ApiResult<Json<DashboardStats>> {
let stats = DashboardStats::get(context).await?;
Ok(Json(stats))
}
/// Get the full list of SOCKS5 proxy configurations.
#[oai(method = "get", path = "/list-proxy", operation_id = "list_proxy")]
async fn list_proxy(&self) -> ApiResult<Json<Vec<Proxy>>> {
async fn list_proxy(&self, context: ClientContext) -> ApiResult<Json<Vec<Proxy>>> {
context
.require_any_permission(vec![
(None, Permission::ACCOUNT_CREATE),
(None, Permission::ROOT),
])
.await?;
let proxies = Proxy::list_all()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
@@ -78,30 +86,36 @@ impl SystemApi {
#[oai(path = "/proxy/:id", method = "delete", operation_id = "remove_proxy")]
async fn remove_proxy(
&self,
/// The name of the OAuth2 configuration to retrieve
/// The ID of the proxy configuration to delete.
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(Proxy::delete(id.0).await?)
}
/// Retrieve a specific proxy configuration by ID
/// Retrieve a specific proxy configuration by ID. Requires root permission.
#[oai(path = "/proxy/:id", method = "get", operation_id = "get_proxy")]
async fn get_proxy(
&self,
/// The name of the OAuth2 configuration to retrieve
/// The ID of the proxy configuration to retrieve.
id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Proxy>> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(Json(Proxy::get(id.0).await?))
}
/// Create a new proxy configuration. Requires root permission.
#[oai(path = "/proxy", method = "post", operation_id = "create_proxy")]
async fn create_proxy(&self, url: PlainText<String>, context: ClientContext) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
let entity = Proxy::new(url.0);
Ok(entity.save().await?)
}
@@ -114,7 +128,28 @@ impl SystemApi {
url: PlainText<String>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(Proxy::update(id.0, url.0).await?)
}
/// Get system configurations.
///
/// Returns a read-only snapshot of the server configuration
/// resolved at startup. Sensitive values are not exposed.
#[oai(
method = "get",
path = "/system-configurations",
operation_id = "get_system_configurations"
)]
async fn get_system_configurations(
&self,
context: ClientContext,
) -> ApiResult<Json<SystemConfigurations>> {
context
.require_permission(None, Permission::ROOT)
.await?;
let config: SystemConfigurations = SystemConfigurations::from(&*SETTINGS);
Ok(Json(config))
}
}

View File

@@ -0,0 +1,231 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::BTreeMap;
use crate::modules::common::auth::ClientContext;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::token::AccessTokenModel;
use crate::modules::users::minimal::MinimalUser;
use crate::modules::users::payload::{
RoleCreateRequest, RoleUpdateRequest, UserCreateRequest, UserUpdateRequest,
};
use crate::modules::users::permissions::Permission;
use crate::modules::users::role::{RoleType, UserRole};
use crate::modules::users::view::UserView;
use crate::modules::users::UserModel;
use poem::web::Path;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
pub struct UsersApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Users")]
impl UsersApi {
#[oai(path = "/list-roles", method = "get", operation_id = "list_roles")]
async fn list_roles(&self, context: ClientContext) -> ApiResult<Json<Vec<UserRole>>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(Json(UserRole::list_all().await?))
}
#[oai(path = "/roles/:id", method = "delete", operation_id = "remove_role")]
async fn remove_role(
&self,
/// The Role ID to delete
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
let id = id.0;
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(UserRole::delete(id).await?)
}
/// Create a new account
#[oai(path = "/roles", method = "post", operation_id = "create_role")]
async fn create_role(
&self,
/// Role creation request payload
payload: Json<RoleCreateRequest>,
context: ClientContext,
) -> ApiResult<Json<UserRole>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
let role = UserRole::create(payload.0).await?;
Ok(Json(role))
}
/// Update an existing account
#[oai(path = "/roles/:id", method = "post", operation_id = "update_role")]
async fn update_role(
&self,
/// The Role ID to update
id: Path<u64>,
/// Role update request payload
payload: Json<RoleUpdateRequest>,
context: ClientContext,
) -> ApiResult<()> {
let id = id.0;
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(UserRole::update(id, payload.0).await?)
}
#[oai(path = "/list-users", method = "get", operation_id = "list_users")]
async fn list_users(&self, context: ClientContext) -> ApiResult<Json<Vec<UserView>>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
let roles = UserRole::list_all().await?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
let users = UserModel::list_all().await?;
let users = users.into_iter().map(|u| u.to_view(&role_lookup)).collect();
Ok(Json(users))
}
#[oai(
path = "/user-tokens/:id",
method = "get",
operation_id = "get_user_tokens"
)]
async fn get_user_tokens(
&self,
id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Vec<AccessTokenModel>>> {
let target_user_id = id.0;
let tokens = AccessTokenModel::get_user_api_tokens(target_user_id).await?;
if context.user.id == target_user_id {
return Ok(Json(tokens));
}
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(Json(tokens))
}
#[oai(path = "/users/:id", method = "delete", operation_id = "remove_user")]
async fn remove_user(
&self,
/// The User ID to delete
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
let id = id.0;
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(UserModel::remove(id).await?)
}
#[oai(path = "/users", method = "post", operation_id = "create_user")]
async fn create_user(
&self,
payload: Json<UserCreateRequest>,
context: ClientContext,
) -> ApiResult<Json<UserView>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
let user = UserModel::create(payload.0).await?;
let roles = UserRole::list_all().await?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
Ok(Json(user.to_view(&role_lookup)))
}
#[oai(path = "/users/:id", method = "post", operation_id = "update_user")]
async fn update_user(
&self,
id: Path<u64>,
payload: Json<UserUpdateRequest>,
context: ClientContext,
) -> ApiResult<()> {
let target_id = id.0;
let current_user_id = context.user.id;
if current_user_id != target_id {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
}
let mut update_data = payload.0;
if current_user_id == target_id
&& !context.has_permission(None, Permission::USER_MANAGE).await
{
update_data.global_roles = None;
update_data.account_access_map = None;
update_data.acl = None;
}
Ok(UserModel::update(target_id, update_data).await?)
}
#[oai(
path = "/current-user",
method = "get",
operation_id = "get_current_user"
)]
async fn get_current_user(&self, context: ClientContext) -> ApiResult<Json<UserView>> {
let roles = UserRole::list_all().await?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
Ok(Json(context.user.to_view(&role_lookup)))
}
#[oai(
path = "/minimal-user-list",
method = "get",
operation_id = "get_minimal_user_list"
)]
async fn get_minimal_user_list(
&self,
context: ClientContext,
) -> ApiResult<Json<Vec<MinimalUser>>> {
let is_admin = context.user.is_admin().await;
let minimal_list = MinimalUser::list_all().await?;
if is_admin {
return Ok(Json(minimal_list));
}
context
.require_permission(None, Permission::USER_VIEW)
.await?;
Ok(Json(minimal_list))
}
#[oai(
path = "/list-account-roles",
method = "get",
operation_id = "list_account_roles"
)]
async fn list_account_roles(&self, context: ClientContext) -> ApiResult<Json<Vec<UserRole>>> {
context
.require_permission(None, Permission::USER_VIEW)
.await?;
let all = UserRole::list_all().await?;
Ok(Json(
all.into_iter()
.filter(|r| matches!(r.role_type, RoleType::Account))
.collect(),
))
}
}

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::common::error::ErrorCapture;
use crate::modules::common::log::Tracing;
use crate::modules::common::tls::rustls_config;
@@ -33,13 +32,14 @@ use crate::modules::common::timeout::{Timeout, TIMEOUT_HEADER};
use crate::raise_error;
use api::create_openapi_service;
use assets::FrontEndAssets;
use http::HeaderValue;
use http::{HeaderValue, Method};
use poem::endpoint::EmbeddedFilesEndpoint;
use poem::listener::{Listener, TcpListener};
use poem::middleware::{CatchPanic, Compression, SetHeader};
use poem::{endpoint::EmbeddedFileEndpoint, middleware::Cors, EndpointExt, Route, Server};
use poem::{get, post};
use public::oauth2::oauth2_callback;
use std::collections::HashSet;
use std::time::Duration;
pub mod api;
@@ -62,7 +62,7 @@ pub async fn start_http_server() -> BichonResult<()> {
};
let api_service = create_openapi_service()
.summary("A self-hosted IMAP/SMTP middleware designed for developers");
.summary("A lightweight, high-performance Rust email archiver with WebUI");
let swagger = api_service.swagger_ui();
let redoc = api_service.redoc();
@@ -78,10 +78,34 @@ pub async fn start_http_server() -> BichonResult<()> {
.with(Timeout)
.with(Tracing);
let mut cors_origins = SETTINGS.bichon_cors_origins.clone();
if cors_origins.is_empty() {
cors_origins = ["*".to_string()].into_iter().collect();
}
let cors_origins: Option<HashSet<String>> = SETTINGS.bichon_cors_origins.clone();
let cors_origins: Vec<String> = cors_origins.unwrap_or_default().into_iter().collect();
let cors = Cors::new()
.allow_origins_fn(move |origin| {
tracing::debug!("CORS: Incoming Origin = {:?}", origin);
tracing::debug!("CORS: Configured origins = {:?}", cors_origins);
if cors_origins.is_empty() {
tracing::debug!("CORS: No origins configured, allowing all");
return true;
}
cors_origins.iter().any(|o| o == origin)
})
//.allow_origins(cors_origins)
.allow_credentials(true)
.allow_methods(&[
Method::GET,
Method::POST,
Method::PUT,
Method::DELETE,
Method::OPTIONS,
Method::HEAD,
Method::PATCH,
])
.allow_headers(vec!["Content-Type", "Authorization", TIMEOUT_HEADER])
.expose_headers(vec!["Accept"])
.max_age(SETTINGS.bichon_cors_max_age);
let cache_static = || {
SetHeader::new().overriding(
@@ -90,14 +114,6 @@ pub async fn start_http_server() -> BichonResult<()> {
)
};
let cors = Cors::new()
.allow_origins(cors_origins)
.allow_credentials(true)
.allow_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"])
.allow_headers(vec!["Content-Type", "Authorization", TIMEOUT_HEADER])
.expose_headers(vec!["Accept"])
.max_age(SETTINGS.bichon_cors_max_age);
let route = Route::new()
.nest("/api-docs/swagger", swagger)
.nest("/api-docs/redoc", redoc)
@@ -122,7 +138,7 @@ pub async fn start_http_server() -> BichonResult<()> {
.with(CatchPanic::new());
let server = Server::new(listener)
.name("RustMailer API Service")
.name("Bichon Service")
.idle_timeout(Duration::from_secs(60))
.run_with_graceful_shutdown(
route.catch_all_error(error_handler),
@@ -130,7 +146,7 @@ pub async fn start_http_server() -> BichonResult<()> {
Some(Duration::from_secs(5)),
);
println!(
"RustMailer API Service is now running on port {}.",
"Bichon Service is now running on port {}.",
SETTINGS.bichon_http_port
);
server

View File

@@ -16,29 +16,41 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::token::root::check_root_password;
use poem::{handler, IntoResponse, Response};
use crate::modules::users::UserModel;
use poem::{handler, web::Json, IntoResponse, Response};
use serde::Deserialize;
use tracing::error;
/// Login endpoint for Root user
#[derive(Deserialize)]
pub struct LoginPayload {
pub username: String,
pub password: String,
}
/// Login endpoint
///
/// Accepts a plain text password and returns the `root_token`
/// on successful authentication.
#[handler]
pub async fn login(password: String) -> Response {
match check_root_password(&password) {
Ok(root_token) => Response::builder()
.status(http::StatusCode::OK)
.content_type("text/plain")
.body(root_token)
.into_response(),
pub async fn login(payload: Json<LoginPayload>) -> Response {
let payload = payload.0;
match UserModel::authenticate_user(payload.username, payload.password).await {
Ok(result) => match serde_json::to_string(&result) {
Ok(json_string) => Response::builder()
.status(http::StatusCode::OK)
.content_type("application/json")
.body(json_string)
.into_response(),
Err(_) => Response::builder()
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
.body("Internal server error during response serialization.")
.into_response(),
},
Err(e) => {
error!("Root login failed: {:?}", e);
error!("Authentication failed with system error: {:?}", e);
Response::builder()
.status(http::StatusCode::UNAUTHORIZED)
.content_type("text/plain")
.body(e.to_string())
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
.body("Authentication system failed.".to_string())
.into_response()
}
}

View File

@@ -16,11 +16,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::settings::io::check_dir_read_write;
use clap::{builder::ValueParser, Parser, ValueEnum};
use std::{collections::HashSet, env, fmt, path::PathBuf, sync::LazyLock};
pub static SETTINGS: LazyLock<Settings> = LazyLock::new(Settings::parse);
pub static SETTINGS: LazyLock<Settings> = LazyLock::new(Settings::init);
#[derive(Debug, Parser)]
#[clap(
@@ -47,16 +47,16 @@ pub struct Settings {
)]
pub bichon_http_port: i32,
/// The IP address that the node binds to, in IPv4 format (e.g., 192.168.1.1).
/// The IP address that the node binds to, in IPv4 or IPv6 format (e.g., 192.168.1.1 or ::1).
#[clap(
long,
env,
default_value = "0.0.0.0",
help = "The IP address that the node binds to, in IPv4 format (e.g., 192.168.1.1). Required in cluster mode.",
help = "The IP address that the node binds to, in IPv4 or IPv6 format (e.g., 192.168.1.1 or ::1).",
value_parser = ValueParser::new(|s: &str| {
// Ensure the input is a valid IPv4 address
if s.parse::<std::net::Ipv4Addr>().is_err() {
return Err("The bind IP address must be a valid IPv4 address.".to_string());
// Ensure the input is a valid IPv4 or IPv6 address
if s.parse::<std::net::Ipv4Addr>().is_err() && s.parse::<std::net::Ipv6Addr>().is_err() {
return Err("The bind IP address must be a valid IPv4 or IPv6 address.".to_string());
}
// If the address is valid, return it
@@ -65,7 +65,7 @@ pub struct Settings {
)]
pub bichon_bind_ip: Option<String>,
/// RustMail public URL (default: "http://localhost:15630")
/// bichon public URL (default: "http://localhost:15630")
#[clap(
long,
default_value = "http://localhost:15630",
@@ -77,7 +77,6 @@ pub struct Settings {
/// CORS allowed origins (default: "*")
#[clap(
long,
default_value = "http://localhost:5173, http://localhost:15630, *",
env,
help = "Set the allowed CORS origins (comma-separated list, e.g., \"https://example.com, https://another.com\")",
value_parser = ValueParser::new(|s: &str| -> Result<HashSet<String>, String> {
@@ -88,7 +87,7 @@ pub struct Settings {
Ok(set)
})
)]
pub bichon_cors_origins: HashSet<String>,
pub bichon_cors_origins: Option<HashSet<String>>,
/// CORS max age in seconds (default: 86400)
#[clap(
@@ -134,11 +133,27 @@ pub struct Settings {
/// bichon encryption password
#[clap(
long,
default_value = "change-this-default-password-now",
env,
help = "Set the encryption password for bichon. ⚠️ Change this default in production!"
default_value = "change-this-default-password-now",
help = "Set the encryption password for bichon. Alternatively, you can use --bichon-encrypt-password-file. If both are set, this parameter takes precedence over the file."
)]
pub bichon_encrypt_password: String,
pub bichon_encrypt_password: Option<String>,
#[clap(
long,
env,
help = "The file containing the encryption password. An alternative to --bichon-encrypt-password."
)]
pub bichon_encrypt_password_file: Option<String>,
/// WebUI token expiration time in seconds (default: 7 days)
#[clap(
long,
default_value = "168",
env,
help = "Set the WebUI token expiration time in hours"
)]
pub bichon_webui_token_expiration_hours: u32,
#[clap(
long,
@@ -146,20 +161,48 @@ pub struct Settings {
help = "Set the file path for bichon database",
value_parser = ValueParser::new(|s: &str| {
let path = PathBuf::from(s);
if !path.is_absolute() {
return Err("Path must be an absolute directory path".to_string());
}
if !path.exists() {
return Err(format!("Path {:?} does not exist", path));
}
if !path.is_dir() {
return Err(format!("Path {:?} is not a directory", path));
return Err("'bichon_root_dir' must be an absolute directory path".to_string());
}
check_dir_read_write(&path)?;
Ok(s.to_string())
})
)]
pub bichon_root_dir: String,
#[clap(
long,
env,
help = "Set the file path for email index directory",
value_parser = ValueParser::new(|s: &str| {
let path = PathBuf::from(s);
if !path.is_absolute() {
return Err("'bichon_index_dir' must be an absolute directory path".to_string());
}
check_dir_read_write(&path)?;
Ok(s.to_string())
})
)]
pub bichon_index_dir: Option<String>,
#[clap(
long,
env,
help = "Set the file path for email data directory",
value_parser = ValueParser::new(|s: &str| {
let path = PathBuf::from(s);
if !path.is_absolute() {
return Err("'bichon_data_dir' must be an absolute directory path".to_string());
}
check_dir_read_write(&path)?;
Ok(s.to_string())
})
)]
pub bichon_data_dir: Option<String>,
#[clap(
long,
env,
@@ -176,19 +219,6 @@ pub struct Settings {
)]
pub bichon_envelope_cache_size: Option<usize>,
/// Enables or disables the access token mechanism for HTTP endpoints.
///
/// When set to `true`, HTTP requests will be subject to access token validation.
/// If the `Authorization` header is missing or the token is invalid, the service will return a 401 Unauthorized response.
/// When set to `false`, access token validation will be skipped.
#[clap(
long,
default_value = "false",
env,
help = "Enables or disables the access token mechanism for HTTP endpoints."
)]
pub bichon_enable_access_token: bool,
/// Enables or disables HTTPS for REST API endpoints.
///
/// When set to `true`, the REST API will use HTTPS with a valid SSL/TLS certificate for secure communication.
@@ -219,6 +249,18 @@ pub struct Settings {
pub bichon_sync_concurrency: Option<u16>,
}
impl Settings {
pub fn init() -> Self {
let s = Self::parse();
if s.bichon_encrypt_password.is_none() && s.bichon_encrypt_password_file.is_none() {
panic!(
"One of --bichon_encrypt_password or --bichon_encrypt_password_file has to be set"
);
}
s
}
}
#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)]
pub enum CompressionAlgorithm {
#[clap(name = "none")]

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::context::Initialize;
use crate::modules::settings::cli::SETTINGS;
use crate::{
@@ -35,7 +34,6 @@ const LOG_DIR: &str = "logs";
const TLS_CERT: &str = "cert.pem";
const TLS_KEY: &str = "key.pem";
pub static DATA_DIR_MANAGER: LazyLock<DataDirManager> =
LazyLock::new(|| DataDirManager::new(PathBuf::from(&SETTINGS.bichon_root_dir)));
@@ -49,7 +47,7 @@ pub struct DataDirManager {
pub tls_key: PathBuf,
pub envelope_dir: PathBuf,
pub eml_dir: PathBuf,
pub log_dir: PathBuf
pub log_dir: PathBuf,
}
impl Initialize for DataDirManager {
@@ -66,6 +64,18 @@ impl Initialize for DataDirManager {
impl DataDirManager {
pub fn new(root_dir: PathBuf) -> Self {
let envelope_dir = if let Some(ref index_dir) = SETTINGS.bichon_index_dir {
PathBuf::from(index_dir)
} else {
root_dir.join(ENVELOPE_DIR)
};
let eml_dir = if let Some(ref data_dir) = SETTINGS.bichon_data_dir {
PathBuf::from(data_dir)
} else {
root_dir.join(EML_DIR)
};
Self {
root_dir: root_dir.clone(),
meta_db: root_dir.join(META_FILE),
@@ -73,9 +83,9 @@ impl DataDirManager {
tls_key: root_dir.join(TLS_KEY),
tls_cert: root_dir.join(TLS_CERT),
log_dir: root_dir.join(LOG_DIR),
envelope_dir: root_dir.join(ENVELOPE_DIR),
envelope_dir,
temp_dir: root_dir.join(TMP_DIR),
eml_dir: root_dir.join(EML_DIR),
eml_dir,
}
}
}

View File

@@ -0,0 +1,41 @@
use std::fs::{self, OpenOptions};
use std::io::{Read, Write};
use std::path::Path;
pub fn check_dir_read_write(path: &Path) -> Result<(), String> {
if !path.exists() {
fs::create_dir_all(path)
.map_err(|e| format!("Cannot create directory {:?}: {}", path, e))?;
}
if !path.is_dir() {
return Err(format!("{:?} is not a directory", path));
}
let test_file = path.join(".bichon_perm_test");
{
let mut f = OpenOptions::new()
.create(true)
.write(true)
.open(&test_file)
.map_err(|e| format!("Directory {:?} is not writable: {}", path, e))?;
f.write_all(b"test")
.map_err(|e| format!("Directory {:?} is not writable: {}", path, e))?;
}
{
let mut buf = Vec::new();
let mut f = OpenOptions::new()
.read(true)
.open(&test_file)
.map_err(|e| format!("Directory {:?} is not readable: {}", path, e))?;
f.read_to_end(&mut buf)
.map_err(|e| format!("Directory {:?} is not readable: {}", path, e))?;
}
let _ = fs::remove_file(&test_file);
Ok(())
}

View File

@@ -16,8 +16,67 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::settings::cli::Settings;
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
pub mod cli;
pub mod dir;
pub mod io;
pub mod proxy;
pub mod system;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct SystemConfigurations {
pub bichon_log_level: String,
pub bichon_http_port: i32,
pub bichon_bind_ip: Option<String>,
pub bichon_public_url: String,
pub bichon_cors_origins: Option<Vec<String>>,
pub bichon_cors_max_age: i32,
pub bichon_ansi_logs: bool,
pub bichon_log_to_file: bool,
pub bichon_json_logs: bool,
pub bichon_max_server_log_files: usize,
pub bichon_encrypt_password_set: bool,
pub bichon_webui_token_expiration_hours: u32,
pub bichon_root_dir: String,
pub bichon_metadata_cache_size: Option<usize>,
pub bichon_envelope_cache_size: Option<usize>,
pub bichon_enable_rest_https: bool,
pub bichon_http_compression_enabled: bool,
pub bichon_sync_concurrency: Option<u16>,
}
impl From<&Settings> for SystemConfigurations {
fn from(s: &Settings) -> Self {
Self {
bichon_log_level: s.bichon_log_level.clone(),
bichon_http_port: s.bichon_http_port,
bichon_bind_ip: s.bichon_bind_ip.clone(),
bichon_public_url: s.bichon_public_url.clone(),
bichon_cors_origins: s
.bichon_cors_origins
.as_ref()
.map(|set| set.iter().cloned().collect()),
bichon_cors_max_age: s.bichon_cors_max_age,
bichon_ansi_logs: s.bichon_ansi_logs,
bichon_log_to_file: s.bichon_log_to_file,
bichon_json_logs: s.bichon_json_logs,
bichon_max_server_log_files: s.bichon_max_server_log_files,
bichon_encrypt_password_set: s.bichon_encrypt_password.is_some()
|| s.bichon_encrypt_password_file.is_some(),
bichon_webui_token_expiration_hours: s.bichon_webui_token_expiration_hours,
bichon_root_dir: s.bichon_root_dir.clone(),
bichon_metadata_cache_size: s.bichon_metadata_cache_size,
bichon_envelope_cache_size: s.bichon_envelope_cache_size,
bichon_enable_rest_https: s.bichon_enable_rest_https,
bichon_http_compression_enabled: s.bichon_http_compression_enabled,
bichon_sync_concurrency: s.bichon_sync_concurrency,
}
}
}

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use native_db::*;
use native_model::{native_model, Model};
use poem_openapi::Object;
@@ -132,10 +131,7 @@ mod tests {
#[test]
fn test_valid_proxy_urls() {
let urls = vec![
"socks5://127.0.0.1:1080",
"http://127.0.0.1:8080",
];
let urls = vec!["socks5://127.0.0.1:1080", "http://127.0.0.1:8080"];
for url in urls {
let proxy = Proxy::new(url.to_string());

View File

@@ -17,10 +17,10 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::database::manager::DB_MANAGER;
use crate::modules::database::{find_impl, upsert_impl};
use crate::modules::error::BichonResult;
use crate::utc_now;
// use crate::modules::database::manager::DB_MANAGER;
// use crate::modules::database::{find_impl, upsert_impl};
// use crate::modules::error::BichonResult;
// use crate::utc_now;
use native_db::*;
use native_model::{native_model, Model};
use serde::{Deserialize, Serialize};
@@ -37,34 +37,34 @@ pub struct SystemSetting {
}
impl SystemSetting {
pub fn new(key: String, value: String) -> Self {
Self {
key,
value,
created_at: utc_now!(),
updated_at: utc_now!(),
}
}
// pub fn new(key: String, value: String) -> Self {
// Self {
// key,
// value,
// created_at: utc_now!(),
// updated_at: utc_now!(),
// }
// }
//overwrite
pub async fn set(&self) -> BichonResult<()> {
upsert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
}
// pub async fn set(&self) -> BichonResult<()> {
// upsert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
// }
pub fn get(key: &str) -> BichonResult<Option<SystemSetting>> {
find_impl(DB_MANAGER.meta_db(), key)
}
// pub fn get(key: &str) -> BichonResult<Option<SystemSetting>> {
// find_impl(DB_MANAGER.meta_db(), key)
// }
// pub async fn list() -> RustMailerResult<Vec<SystemSetting>> {
// list_all_impl(DB_MANAGER.metadata_db()).await
// }
pub fn get_existing_value(key: &str) -> BichonResult<Option<String>> {
let setting = Self::get(key)?;
Ok(setting.map(|s| s.value))
}
// pub fn get_existing_value(key: &str) -> BichonResult<Option<String>> {
// let setting = Self::get(key)?;
// Ok(setting.map(|s| s.value))
// }
pub async fn set_value(key: &str, value: String) -> BichonResult<()> {
let setting = Self::new(key.to_string(), value);
setting.set().await
}
// pub async fn set_value(key: &str, value: String) -> BichonResult<()> {
// let setting = Self::new(key.to_string(), value);
// setting.set().await
// }
}

View File

@@ -16,12 +16,17 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::HashMap;
use crate::modules::account::migration::AccountModel;
use crate::modules::database::delete_impl;
use super::error::code::ErrorCode;
use crate::modules::database::manager::DB_MANAGER;
use crate::modules::database::{
async_find_impl, delete_impl, filter_by_secondary_key_impl, with_transaction,
};
use crate::modules::database::{insert_impl, list_all_impl, update_impl};
use crate::modules::token::payload::AccessTokenUpdateRequest;
use crate::modules::settings::cli::SETTINGS;
use crate::modules::token::view::AccessTokenResp;
use crate::modules::users::UserModel;
use crate::raise_error;
use crate::{
generate_token, modules::error::BichonResult,
@@ -29,259 +34,227 @@ use crate::{
};
use native_db::*;
use native_model::{native_model, Model};
use poem_openapi::Object;
use poem_openapi::{Enum, Object};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::net::IpAddr;
use super::error::code::ErrorCode;
pub mod payload;
pub mod root;
pub mod view;
// Starting from version 0.2.0, this model is deprecated/no longer used
// #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
// #[native_model(id = 1, version = 1)]
// #[native_db]
// pub struct AccessToken {
// /// The unique token string used for authentication
// #[primary_key]
// pub token: String,
// /// A set of account information associated with the token.
// pub accounts: BTreeSet<AccountInfo>,
// /// The timestamp (in milliseconds since epoch) when the token was created.
// pub created_at: i64,
// /// The timestamp (in milliseconds since epoch) when the token was last updated.
// pub updated_at: i64,
// /// An optional description of the token's purpose or usage.
// pub description: Option<String>,
// /// The timestamp (in milliseconds since epoch) when the token was last used.
// pub last_access_at: i64,
// /// Optional access control settings
// pub acl: Option<AccessControl>,
// }
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Enum)]
pub enum TokenType {
WebUI,
Api,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
#[native_model(id = 1, version = 1)]
#[native_model(id = 11, version = 1)]
#[native_db]
pub struct AccessToken {
pub struct AccessTokenModel {
/// The ID of the user who owns this token
#[secondary_key]
pub user_id: u64,
/// The unique token string used for authentication
#[primary_key]
pub token: String,
/// A set of account information associated with the token.
pub accounts: BTreeSet<AccountInfo>,
/// An optional name of the token.
pub name: Option<String>,
/// Token type: WebUI or API
pub token_type: TokenType,
/// The timestamp (in milliseconds since epoch) when the token was created.
pub created_at: i64,
/// The timestamp (in milliseconds since epoch) when the token was last updated.
pub updated_at: i64,
/// An optional description of the token's purpose or usage.
pub description: Option<String>,
/// The timestamp (in milliseconds since epoch) when the token expires.
/// None means the token does not expire (this applies only to API tokens).
pub expire_at: Option<i64>,
/// The timestamp (in milliseconds since epoch) when the token was last used.
pub last_access_at: i64,
/// Optional access control settings
pub acl: Option<AccessControl>,
}
#[derive(Clone, Debug, Hash, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct AccountInfo {
/// The unique identifier for the account.
pub id: u64,
/// The email address associated with the account.
pub email: String,
}
impl Ord for AccountInfo {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.id.cmp(&other.id)
}
}
impl PartialOrd for AccountInfo {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct AccessControl {
/// An optional set of valid IPv4 or IPv6 addresses allowed to use the access token.
pub ip_whitelist: Option<BTreeSet<String>>,
/// An optional rate limit configuration for the access token.
pub rate_limit: Option<RateLimit>,
}
impl AccessControl {
pub fn validate(&self) -> BichonResult<()> {
if let Some(ip_whitelist) = &self.ip_whitelist {
for ip in ip_whitelist {
if ip.parse::<IpAddr>().is_err() {
return Err(raise_error!(
format!("Invalid IP address: {}", ip),
ErrorCode::InvalidParameter
));
}
}
}
// Validate rate limit
if let Some(rate_limit) = &self.rate_limit {
if rate_limit.interval < 1 {
return Err(raise_error!(
"Rate limit interval must be at least 1 second".into(),
ErrorCode::InvalidParameter
));
}
if rate_limit.quota < 1 {
return Err(raise_error!(
"Rate limit quota must be at least 1".into(),
ErrorCode::InvalidParameter
));
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct RateLimit {
/// The time window in seconds for the rate limit.
pub interval: u64,
/// The maximum number of allowed requests within the time window.
pub quota: u32,
}
impl AccessToken {
pub fn new(
impl AccessTokenModel {
pub fn new_api_token(
token: String,
accounts: BTreeSet<AccountInfo>,
description: Option<String>,
acl: Option<AccessControl>,
user_id: u64,
name: Option<String>,
expire_at: Option<i64>,
) -> Self {
Self {
token,
accounts,
created_at: utc_now!(),
updated_at: utc_now!(),
description,
last_access_at: Default::default(),
acl,
name,
user_id,
token_type: TokenType::Api,
expire_at,
}
}
pub async fn try_update_access_timestamp(token: &str) -> BichonResult<AccessToken> {
let token = token.to_string();
update_impl(
DB_MANAGER.meta_db(),
|rw| {
rw.get()
.primary::<AccessToken>(token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!("Token not exist.".into(), ErrorCode::ResourceNotFound)
})
},
|current| {
let mut updated = current.clone();
updated.last_access_at = utc_now!();
Ok(updated)
},
)
.await
pub fn new_webui_token(user_id: u64) -> AccessTokenModel {
let now = utc_now!();
AccessTokenModel {
token: generate_token!(128),
created_at: now,
updated_at: now,
last_access_at: Default::default(),
name: None,
user_id,
token_type: TokenType::WebUI,
expire_at: None,
}
}
pub async fn grant_account_access(token: &str, account: AccountInfo) -> BichonResult<()> {
let token = token.to_string();
update_impl(
pub async fn reset_webui_token(user_id: u64) -> BichonResult<String> {
let old_token = Self::get_user_webui_token(user_id).await?;
let new_token = Self::new_webui_token(user_id);
let new_token_str = new_token.token.clone();
match old_token {
Some(old) => {
with_transaction(DB_MANAGER.meta_db(), move |rw| {
rw.remove(old)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.insert(new_token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await?;
}
None => {
insert_impl(DB_MANAGER.meta_db(), new_token).await?;
}
}
Ok(new_token_str)
}
pub async fn get_user_webui_token(user_id: u64) -> BichonResult<Option<AccessTokenModel>> {
let tokens = filter_by_secondary_key_impl::<AccessTokenModel>(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<AccessToken>(token.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!(
"The access token with token={} that you want to modify was not found.",
token
),
ErrorCode::ResourceNotFound
)
})
},
|current| {
let mut updated = current.clone();
updated.accounts.insert(account);
updated.updated_at = utc_now!();
Ok(updated)
},
AccessTokenModelKey::user_id,
user_id,
)
.await?;
Ok(())
Ok(tokens
.into_iter()
.find(|t| t.token_type == TokenType::WebUI))
}
pub async fn update(token: &str, request: AccessTokenUpdateRequest) -> BichonResult<()> {
if request.should_skip_update() {
return Err(raise_error!(
"No changes detected in access scopes, description, or accounts. \
Please modify at least one of these fields to perform an update."
.into(),
ErrorCode::InvalidParameter
));
}
request.validate().await?;
pub async fn get_user_api_tokens(user_id: u64) -> BichonResult<Vec<AccessTokenModel>> {
let tokens = filter_by_secondary_key_impl::<AccessTokenModel>(
DB_MANAGER.meta_db(),
AccessTokenModelKey::user_id,
user_id,
)
.await?;
let account_infos = if let Some(accounts) = &request.accounts {
let mut account_infos = BTreeSet::new();
for account_id in accounts {
let account = AccountModel::get(*account_id).await?;
account_infos.insert(AccountInfo {
id: *account_id,
email: account.email,
});
Ok(tokens
.into_iter()
.filter(|t| t.token_type == TokenType::Api)
.collect())
}
pub async fn resolve_user_from_token(token: &str) -> BichonResult<UserModel> {
let token = token.to_string();
let token_option = async_find_impl::<AccessTokenModel>(DB_MANAGER.meta_db(), token).await?;
let token = match token_option {
Some(token) => token,
None => {
return Err(raise_error!(
"Permission denied: no valid access token provided.".into(),
ErrorCode::PermissionDenied
))
}
account_infos
} else {
BTreeSet::new()
};
let token = token.to_string();
update_impl(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<AccessToken>(token.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!(
"The access token with token={} that you want to modify was not found.",
token
),
ErrorCode::ResourceNotFound
)
})
},
move |current| {
let mut updated = current.clone();
if let Some(description) = request.description {
updated.description = Some(description);
}
if matches!(token.token_type, TokenType::WebUI) {
let life = utc_now!() - token.created_at;
let max_life = SETTINGS.bichon_webui_token_expiration_hours * 60 * 60 * 1000;
if request.accounts.is_some() {
updated.accounts = account_infos;
}
if let Some(acl) = request.acl {
updated.acl = Some(acl);
}
updated.updated_at = utc_now!();
Ok(updated)
},
)
.await?;
Ok(())
}
pub async fn create(request: AccessTokenCreateRequest) -> BichonResult<String> {
// Validate request parameters first
request.validate().await?;
let AccessTokenCreateRequest {
accounts,
description,
acl,
} = request;
let mut account_infos = BTreeSet::new();
for &account_id in &accounts {
let account = AccountModel::get(account_id).await?;
account_infos.insert(AccountInfo {
id: account_id,
email: account.email,
});
if life > (max_life as i64) {
return Err(raise_error!(
"Permission denied: the WebUI token has expired.".into(),
ErrorCode::PermissionDenied
));
}
}
if matches!(token.token_type, TokenType::Api) {
if let Some(expire_at) = token.expire_at {
if utc_now!() > expire_at {
return Err(raise_error!(
"Your API token has expired and is no longer valid.".into(),
ErrorCode::PermissionDenied
));
}
}
let token = token.token.clone();
update_impl(
DB_MANAGER.meta_db(),
|rw| {
rw.get()
.primary::<AccessTokenModel>(token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
"The access token does not exist or has been reset.".into(),
ErrorCode::ResourceNotFound
)
})
},
|current| {
let mut updated = current.clone();
updated.last_access_at = utc_now!();
Ok(updated)
},
)
.await?;
}
let user = UserModel::find(token.user_id)
.await?
.ok_or_else(|| raise_error!("The user associated with this access token does not exist or may have been deleted.".into(), ErrorCode::ResourceNotFound))?;
Ok(user)
}
pub async fn create_api_token(
user_id: u64,
request: AccessTokenCreateRequest,
) -> BichonResult<String> {
// Validate request parameters first
request.validate().await?;
let expire_at = request
.expire_in
.map(|hours| utc_now!() + (hours as i64) * 60 * 60 * 1000);
let token = generate_token!(128);
let access_token = AccessToken::new(token.clone(), account_infos, description, acl);
let access_token =
AccessTokenModel::new_api_token(token.clone(), user_id, request.name, expire_at);
insert_impl(DB_MANAGER.meta_db(), access_token).await?;
Ok(token)
}
@@ -290,7 +263,7 @@ impl AccessToken {
let token = token.to_string();
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get()
.primary::<AccessToken>(token.clone())
.primary::<AccessTokenModel>(token.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
@@ -302,56 +275,47 @@ impl AccessToken {
.await
}
pub async fn list_all() -> BichonResult<Vec<AccessToken>> {
list_all_impl(DB_MANAGER.meta_db()).await
pub async fn get_token(token: &str) -> BichonResult<AccessTokenModel> {
async_find_impl(DB_MANAGER.meta_db(), token.to_string())
.await?
.ok_or_else(|| {
raise_error!(
format!("Access token '{}' not found", token),
ErrorCode::ResourceNotFound
)
})
}
pub async fn list_account_tokens(account_id: u64) -> BichonResult<Vec<AccessToken>> {
let all = AccessToken::list_all().await?;
let result: Vec<AccessToken> = all
pub async fn list_all_api_tokens() -> BichonResult<Vec<AccessTokenResp>> {
let users = UserModel::list_all().await?;
let mut all = list_all_impl::<AccessTokenModel>(DB_MANAGER.meta_db()).await?;
all.retain(|t| t.token_type == TokenType::Api);
let user_map: HashMap<u64, UserModel> = users.into_iter().map(|u| (u.id, u)).collect();
let resp = all
.into_iter()
.filter(|e| {
e.accounts
.iter()
.any(|account_info| account_info.id == account_id)
.map(|token| {
let user = user_map.get(&token.user_id);
AccessTokenResp {
user_name: user
.map(|u| u.username.clone())
.unwrap_or_else(|| "Unknown".to_string()),
user_email: user
.map(|u| u.email.clone())
.unwrap_or_else(|| "N/A".to_string()),
user_id: token.user_id,
name: token.name,
token: token.token,
token_type: token.token_type,
created_at: token.created_at,
updated_at: token.updated_at,
expire_at: token.expire_at,
last_access_at: token.last_access_at,
}
})
.collect();
Ok(result)
}
pub async fn cleanup_account(account_id: u64) -> BichonResult<()> {
let tokens = Self::list_account_tokens(account_id).await?;
if tokens.is_empty() {
return Ok(());
}
for token in tokens {
update_impl(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<AccessToken>(token.token.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("Cannot find access token, {}", token.token),
ErrorCode::ResourceNotFound
)
})
},
move |current| {
let mut updated = current.clone();
updated.updated_at = utc_now!();
updated.accounts.retain(|account| account.id != account_id);
Ok(updated)
},
)
.await?;
}
Ok(())
}
pub fn can_access_account(&self, account_id: u64) -> bool {
self.accounts.iter().any(|account| account.id == account_id)
Ok(resp)
}
}

View File

@@ -16,15 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::collections::BTreeSet;
use crate::{
modules::{
account::migration::AccountModel,
error::{code::ErrorCode, BichonResult},
token::AccessControl,
},
modules::error::{code::ErrorCode, BichonResult},
raise_error,
};
use poem_openapi::Object;
@@ -32,91 +25,28 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize, Object)]
pub struct AccessTokenCreateRequest {
/// A set of account information associated with the token.
pub accounts: BTreeSet<u64>,
/// An optional description of the token's purpose or usage.
#[oai(validator(max_length = "255"))]
pub description: Option<String>,
/// Optional access control settings
pub acl: Option<AccessControl>,
#[oai(validator(max_length = "32"))]
pub name: Option<String>,
/// The expiration interval for this token, in hours.
/// None means the token does not expire (this applies only to API tokens).
pub expire_in: Option<u64>,
/// The ID of the user for whom the token is being created.
/// If not specified, the token will be created for the current authenticated user.
/// Accessing this for another user typically requires `USER_MANAGE` permissions.
pub user_id: Option<u64>,
}
impl AccessTokenCreateRequest {
pub async fn validate(&self) -> BichonResult<()> {
if let Some(acl) = &self.acl {
acl.validate()?;
}
if self.accounts.is_empty() {
return Err(raise_error!(
"Account list cannot be empty. Please provide at least one valid account ID."
.into(),
ErrorCode::InvalidParameter
));
}
let mut not_found = Vec::new();
for account_id in &self.accounts {
if AccountModel::find(*account_id).await?.is_none() {
not_found.push(*account_id);
}
}
if !not_found.is_empty() {
return Err(raise_error!(
format!("The following account IDs were not found: {}. Please provide valid account IDs.", not_found.iter().map(u64::to_string).collect::<Vec<_>>().join(", ")).into(),
ErrorCode::InvalidParameter
));
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Object)]
pub struct AccessTokenUpdateRequest {
/// A set of account information associated with the token.
pub accounts: Option<BTreeSet<u64>>,
/// An optional description of the token's purpose or usage.
#[oai(validator(max_length = "255"))]
pub description: Option<String>,
/// Optional access control settings
pub acl: Option<AccessControl>,
}
impl AccessTokenUpdateRequest {
pub async fn validate(&self) -> BichonResult<()> {
if let Some(acl) = &self.acl {
acl.validate()?;
}
if let Some(accounts) = &self.accounts {
if accounts.is_empty() {
if let Some(expire_in) = self.expire_in {
if expire_in == 0 {
return Err(raise_error!(
"Account list cannot be empty. Please provide at least one valid account ID."
.into(),
"expire_in must be a positive duration in hours; zero is not allowed.".into(),
ErrorCode::InvalidParameter
));
}
let mut not_found = Vec::new();
for account_id in accounts {
if AccountModel::find(*account_id).await?.is_none() {
not_found.push(*account_id);
}
}
if !not_found.is_empty() {
return Err(raise_error!(
format!("The following account IDs were not found: {}. Please provide valid account IDs.", not_found.iter().map(u64::to_string).collect::<Vec<_>>().join(", ")).into(),
ErrorCode::InvalidParameter
));
}
}
Ok(())
}
}
impl AccessTokenUpdateRequest {
pub fn should_skip_update(&self) -> bool {
self.description.is_none() && self.accounts.is_none() && self.acl.is_none()
}
}

View File

@@ -16,110 +16,109 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// use crate::{
// decrypt, encrypt, generate_token,
// modules::{
// error::{code::ErrorCode, BichonResult},
// settings::{dir::DATA_DIR_MANAGER, system::SystemSetting},
// },
// raise_error,
// };
// use std::fs::File;
// use std::io::Write;
use crate::{
decrypt, encrypt, generate_token,
modules::{
error::{code::ErrorCode, BichonResult},
settings::{dir::DATA_DIR_MANAGER, system::SystemSetting},
},
raise_error,
};
use std::fs::File;
use std::io::Write;
// pub const ROOT_TOKEN: &str = "root-token";
// pub const ROOT_PASSWORD: &str = "root-password";
// pub const DEFAULT_ROOT_PASSWORD: &str = "root";
// pub const ROOT_TOKEN_FILE: &str = "root";
pub const ROOT_TOKEN: &str = "root-token";
pub const ROOT_PASSWORD: &str = "root-password";
pub const DEFAULT_ROOT_PASSWORD: &str = "root";
pub const ROOT_TOKEN_FILE: &str = "root";
// async fn get_or_generate(
// key: &str,
// generate: impl Fn() -> String,
// save_file_name: Option<&str>,
// force: bool,
// ) -> BichonResult<String> {
// if let Some(existing_value) = SystemSetting::get_existing_value(key)? {
// if force {
// // If force is true, write the existing value to the file
// if let Some(filename) = save_file_name {
// save_to_file(&existing_value.to_string(), filename).await?;
// }
// }
// Ok(existing_value)
// } else {
// // If no value exists, generate a new value
// let new_value = generate();
// SystemSetting::set_value(key, new_value.clone()).await?;
async fn get_or_generate(
key: &str,
generate: impl Fn() -> String,
save_file_name: Option<&str>,
force: bool,
) -> BichonResult<String> {
if let Some(existing_value) = SystemSetting::get_existing_value(key)? {
if force {
// If force is true, write the existing value to the file
if let Some(filename) = save_file_name {
save_to_file(&existing_value.to_string(), filename).await?;
}
}
Ok(existing_value)
} else {
// If no value exists, generate a new value
let new_value = generate();
SystemSetting::set_value(key, new_value.clone()).await?;
// // Write the new value to the file, if specified
// if let Some(filename) = save_file_name {
// save_to_file(&new_value.to_string(), filename).await?;
// }
// Ok(new_value)
// }
// }
// Write the new value to the file, if specified
if let Some(filename) = save_file_name {
save_to_file(&new_value.to_string(), filename).await?;
}
Ok(new_value)
}
}
// pub async fn ensure_root_token() -> BichonResult<()> {
// get_or_generate(
// ROOT_TOKEN,
// || generate_token!(128),
// Some(ROOT_TOKEN_FILE),
// true,
// )
// .await?;
// Ok(())
// }
pub async fn ensure_root_token() -> BichonResult<()> {
get_or_generate(
ROOT_TOKEN,
|| generate_token!(128),
Some(ROOT_TOKEN_FILE),
true,
)
.await?;
Ok(())
}
// pub async fn reset_root_token() -> BichonResult<String> {
// let new_token = generate_token!(128);
// save_new_token(&new_token).await?;
// save_to_file(&new_token, ROOT_TOKEN_FILE).await?;
// Ok(new_token)
// }
pub async fn reset_root_token() -> BichonResult<String> {
let new_token = generate_token!(128);
save_new_token(&new_token).await?;
save_to_file(&new_token, ROOT_TOKEN_FILE).await?;
Ok(new_token)
}
// async fn save_new_token(token: &str) -> BichonResult<()> {
// let setting = SystemSetting::new(ROOT_TOKEN.to_string(), token.to_string());
// setting.set().await
// }
async fn save_new_token(token: &str) -> BichonResult<()> {
let setting = SystemSetting::new(ROOT_TOKEN.to_string(), token.to_string());
setting.set().await
}
// async fn save_to_file(content: &str, filename: &str) -> BichonResult<()> {
// let file_path = DATA_DIR_MANAGER.root_dir.join(filename);
// let mut file = File::create(&file_path)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// writeln!(file, "{}", content)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// Ok(())
// }
async fn save_to_file(content: &str, filename: &str) -> BichonResult<()> {
let file_path = DATA_DIR_MANAGER.root_dir.join(filename);
let mut file = File::create(&file_path)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
writeln!(file, "{}", content)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
}
// pub fn check_root_password(password: &str) -> BichonResult<String> {
// let stored_encrypted_password = SystemSetting::get_existing_value(ROOT_PASSWORD)?;
// let matched = match stored_encrypted_password {
// Some(ref stored) => {
// let decrypted = decrypt!(stored)?;
// decrypted == password
// }
// None => DEFAULT_ROOT_PASSWORD == password,
// };
pub fn check_root_password(password: &str) -> BichonResult<String> {
let stored_encrypted_password = SystemSetting::get_existing_value(ROOT_PASSWORD)?;
let matched = match stored_encrypted_password {
Some(ref stored) => {
let decrypted = decrypt!(stored)?;
decrypted == password
}
None => DEFAULT_ROOT_PASSWORD == password,
};
// if !matched {
// return Err(raise_error!(
// "Invalid password".into(),
// ErrorCode::PermissionDenied
// ));
// }
if !matched {
return Err(raise_error!(
"Invalid password".into(),
ErrorCode::PermissionDenied
));
}
// let root_token = SystemSetting::get_existing_value(ROOT_TOKEN)?.ok_or_else(|| {
// raise_error!(
// "Root token not found — this should never happen".into(),
// ErrorCode::InternalError
// )
// })?;
let root_token = SystemSetting::get_existing_value(ROOT_TOKEN)?.ok_or_else(|| {
raise_error!(
"Root token not found — this should never happen".into(),
ErrorCode::InternalError
)
})?;
// Ok(root_token)
// }
Ok(root_token)
}
pub async fn set_root_password(new_password: &str) -> BichonResult<()> {
let encrypted_password = encrypt!(new_password)?;
SystemSetting::set_value(ROOT_PASSWORD, encrypted_password).await
}
// pub async fn set_root_password(new_password: &str) -> BichonResult<()> {
// let encrypted_password = encrypt!(new_password)?;
// SystemSetting::set_value(ROOT_PASSWORD, encrypted_password).await
// }

44
src/modules/token/view.rs Normal file
View File

@@ -0,0 +1,44 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::token::TokenType;
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct AccessTokenResp {
pub user_id: u64,
pub token: String,
/// An optional name of the token.
pub name: Option<String>,
/// Token type: WebUI or API
pub token_type: TokenType,
/// The timestamp (in milliseconds since epoch) when the token was created.
pub created_at: i64,
/// The timestamp (in milliseconds since epoch) when the token was last updated.
pub updated_at: i64,
/// The timestamp (in milliseconds since epoch) when the token expires.
/// None means the token does not expire (this applies only to API tokens).
pub expire_at: Option<i64>,
/// The timestamp (in milliseconds since epoch) when the token was last used.
pub last_access_at: i64,
pub user_name: String,
pub user_email: String,
}

73
src/modules/users/acl.rs Normal file
View File

@@ -0,0 +1,73 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{collections::BTreeSet, net::IpAddr};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::{modules::error::{BichonResult, code::ErrorCode}, raise_error};
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct RateLimit {
/// The time window in seconds for the rate limit.
pub interval: u64,
/// The maximum number of allowed requests within the time window.
pub quota: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct AccessControl {
/// An optional set of valid IPv4 or IPv6 addresses allowed to use the access token.
pub ip_whitelist: Option<BTreeSet<String>>,
/// An optional rate limit configuration for the access token.
pub rate_limit: Option<RateLimit>,
}
impl AccessControl {
pub fn validate(&self) -> BichonResult<()> {
if let Some(ip_whitelist) = &self.ip_whitelist {
for ip in ip_whitelist {
if ip.parse::<IpAddr>().is_err() {
return Err(raise_error!(
format!("Invalid IP address: {}", ip),
ErrorCode::InvalidParameter
));
}
}
}
// Validate rate limit
if let Some(rate_limit) = &self.rate_limit {
if rate_limit.interval < 1 {
return Err(raise_error!(
"Rate limit interval must be at least 1 second".into(),
ErrorCode::InvalidParameter
));
}
if rate_limit.quota < 1 {
return Err(raise_error!(
"Rate limit quota must be at least 1".into(),
ErrorCode::InvalidParameter
));
}
}
Ok(())
}
}

View File

@@ -16,30 +16,17 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::{
context::Initialize,
error::BichonResult,
users::{role::UserRole, UserModel},
};
interface AccountInfo {
id: number;
email: string;
pub struct UserManager;
impl Initialize for UserManager {
async fn initialize() -> BichonResult<()> {
UserRole::ensure_default_roles_exists().await?;
UserModel::ensure_default_admin_exists().await
}
}
interface RateLimit {
quota: number;
interval: number;
}
interface AccessControl {
ip_whitelist?: string[];
rate_limit?: RateLimit;
}
interface AccessToken {
token: string;
accounts: AccountInfo[];
created_at: number;
updated_at: number;
description?: string;
last_access_at: number;
acl?: AccessControl;
}
export type { AccessToken, AccountInfo, AccessControl, RateLimit };

View File

@@ -0,0 +1,49 @@
//
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::modules::{
database::{list_all_impl, manager::DB_MANAGER},
error::BichonResult,
users::UserModel,
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct MinimalUser {
pub id: u64,
pub username: String,
pub email: String,
}
impl MinimalUser {
pub async fn list_all() -> BichonResult<Vec<MinimalUser>> {
let all_users = list_all_impl::<UserModel>(DB_MANAGER.meta_db()).await?;
let minimal_list = all_users
.into_iter()
.map(|user| MinimalUser {
id: user.id,
username: user.username,
email: user.email,
})
.collect();
Ok(minimal_list)
}
}

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