mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-07-16 00:44:56 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
837fb76868 | ||
|
|
5428ea5caa | ||
|
|
2de2dfdb81 | ||
|
|
f0d934259c | ||
|
|
735c3b073a | ||
|
|
e2cf3633fe | ||
|
|
c0589a9ce9 | ||
|
|
2135572adb | ||
|
|
697e262b5d | ||
|
|
5a65c4448d |
+5
-1
@@ -30,7 +30,7 @@ RUN set -eux \
|
||||
# Purpose: Installs s6-overlay and rootfs
|
||||
# Comments:
|
||||
# - Don't leave anything extra in here either
|
||||
FROM ghcr.io/astral-sh/uv:0.11.19-python3.12-trixie-slim AS s6-overlay-base
|
||||
FROM ghcr.io/astral-sh/uv:0.11.28-python3.12-trixie-slim AS s6-overlay-base
|
||||
|
||||
WORKDIR /usr/src/s6
|
||||
|
||||
@@ -238,6 +238,10 @@ RUN set -eux \
|
||||
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \
|
||||
&& echo "Making fontconfig cache writable for arbitrary container UIDs" \
|
||||
&& chmod 1777 /var/cache/fontconfig \
|
||||
&& echo "Making /run world-writable for rootless operation" \
|
||||
&& chmod 1777 /run \
|
||||
&& echo "Removing setuid from s6-overlay-suexec for rootless compat" \
|
||||
&& chmod u-s /command/s6-overlay-suexec \
|
||||
&& echo "Collecting static files" \
|
||||
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py collectstatic --clear --no-input --link \
|
||||
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py compilemessages \
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# correct networking for the tests
|
||||
services:
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.33
|
||||
image: docker.io/gotenberg/gotenberg:8.34
|
||||
hostname: gotenberg
|
||||
container_name: gotenberg
|
||||
network_mode: host
|
||||
@@ -24,7 +24,7 @@ services:
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
greenmail:
|
||||
image: docker.io/greenmail/standalone:2.1.8
|
||||
image: docker.io/greenmail/standalone:2.1.9
|
||||
hostname: greenmail
|
||||
container_name: greenmail
|
||||
environment:
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
- "3143:3143" # IMAP
|
||||
restart: unless-stopped
|
||||
nginx:
|
||||
image: docker.io/nginx:1.31.1-alpine
|
||||
image: docker.io/nginx:1.31.2-alpine
|
||||
hostname: nginx
|
||||
container_name: nginx
|
||||
ports:
|
||||
|
||||
@@ -72,7 +72,7 @@ services:
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.33
|
||||
image: docker.io/gotenberg/gotenberg:8.34
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.33
|
||||
image: docker.io/gotenberg/gotenberg:8.34
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.33
|
||||
image: docker.io/gotenberg/gotenberg:8.34
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
||||
+1
-1
@@ -142,7 +142,7 @@ a [superuser](usage.md#superusers) account.
|
||||
|
||||
It is not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`.
|
||||
|
||||
If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping:
|
||||
If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping. Any UID and GID is supported:
|
||||
|
||||
```yaml
|
||||
webserver:
|
||||
|
||||
+9
-9
@@ -16,12 +16,12 @@ classifiers = [
|
||||
dependencies = [
|
||||
"azure-ai-documentintelligence>=1.0.2",
|
||||
"babel>=2.17",
|
||||
"bleach~=6.4.0",
|
||||
"bleach~=6.3.0",
|
||||
"celery[redis]~=5.6.2",
|
||||
"channels~=4.2",
|
||||
"channels-redis~=4.2",
|
||||
"concurrent-log-handler~=0.9.25",
|
||||
"dateparser~=1.2",
|
||||
"dateparser~=1.4",
|
||||
# WARNING: django does not use semver.
|
||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||
"django~=5.2.13",
|
||||
@@ -32,7 +32,7 @@ dependencies = [
|
||||
"django-cors-headers~=4.9.0",
|
||||
"django-extensions~=4.1",
|
||||
"django-filter~=25.1",
|
||||
"django-guardian~=3.3.0",
|
||||
"django-guardian~=3.3.2",
|
||||
"django-multiselectfield~=1.0.1",
|
||||
"django-rich~=2.2.0",
|
||||
"django-soft-delete~=1.0.18",
|
||||
@@ -42,21 +42,21 @@ dependencies = [
|
||||
"drf-spectacular~=0.28",
|
||||
"drf-spectacular-sidecar~=2026.5.1",
|
||||
"drf-writable-nested~=0.7.1",
|
||||
"filelock~=3.29.0",
|
||||
"filelock~=3.29.7",
|
||||
"flower~=2.0.1",
|
||||
"gotenberg-client~=0.14.0",
|
||||
"httpx-oauth~=0.16",
|
||||
"ijson>=3.2",
|
||||
"ijson>=3.5.1",
|
||||
"imap-tools~=1.13.0",
|
||||
"jinja2~=3.1.5",
|
||||
"langdetect~=1.0.9",
|
||||
"llama-index-core>=0.14.22",
|
||||
"llama-index-core>=0.14.23",
|
||||
"llama-index-embeddings-huggingface>=0.6.1",
|
||||
"llama-index-embeddings-ollama>=0.9",
|
||||
"llama-index-embeddings-openai-like>=0.2.2",
|
||||
"llama-index-llms-ollama>=0.9.1",
|
||||
"llama-index-llms-openai-like>=0.7.1",
|
||||
"nltk~=3.10.0",
|
||||
"nltk~=3.9.1",
|
||||
"ocrmypdf~=17.4.2",
|
||||
"openai>=2.32",
|
||||
"pathvalidate~=3.3.1",
|
||||
@@ -101,11 +101,11 @@ dev = [
|
||||
{ include-group = "testing" },
|
||||
]
|
||||
docs = [
|
||||
"zensical>=0.0.43",
|
||||
"zensical>=0.0.47",
|
||||
]
|
||||
lint = [
|
||||
"prek~=0.3.10",
|
||||
"ruff~=0.15.15",
|
||||
"ruff~=0.15.20",
|
||||
]
|
||||
testing = [
|
||||
"daphne",
|
||||
|
||||
+37
-37
@@ -379,11 +379,11 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">272</context>
|
||||
<context context-type="linenumber">273</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">274</context>
|
||||
<context context-type="linenumber">275</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5890330709052835856" datatype="html">
|
||||
@@ -724,11 +724,11 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">307</context>
|
||||
<context context-type="linenumber">308</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">310</context>
|
||||
<context context-type="linenumber">311</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2272120016352772836" datatype="html">
|
||||
@@ -1135,11 +1135,11 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">232</context>
|
||||
<context context-type="linenumber">233</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">234</context>
|
||||
<context context-type="linenumber">235</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/saved-views/saved-views.component.html</context>
|
||||
@@ -1696,7 +1696,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">203</context>
|
||||
<context context-type="linenumber">204</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/input/tags/tags.component.ts</context>
|
||||
@@ -1801,11 +1801,11 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">295</context>
|
||||
<context context-type="linenumber">296</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">297</context>
|
||||
<context context-type="linenumber">298</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8492095365580052820" datatype="html">
|
||||
@@ -2513,11 +2513,11 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">255</context>
|
||||
<context context-type="linenumber">256</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">258</context>
|
||||
<context context-type="linenumber">259</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3818027200170621545" datatype="html">
|
||||
@@ -2870,11 +2870,11 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">286</context>
|
||||
<context context-type="linenumber">287</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">288</context>
|
||||
<context context-type="linenumber">289</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4569276013106377105" datatype="html">
|
||||
@@ -3211,11 +3211,11 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">316</context>
|
||||
<context context-type="linenumber">317</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">319</context>
|
||||
<context context-type="linenumber">320</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="472206565520537964" datatype="html">
|
||||
@@ -3240,36 +3240,36 @@
|
||||
<source>Close all</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">162</context>
|
||||
<context context-type="linenumber">163</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">164</context>
|
||||
<context context-type="linenumber">165</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3897348120591552265" datatype="html">
|
||||
<source>Manage</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">173</context>
|
||||
<context context-type="linenumber">174</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8008131619909556709" datatype="html">
|
||||
<source>Attributes</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">180</context>
|
||||
<context context-type="linenumber">181</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">182</context>
|
||||
<context context-type="linenumber">183</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7437910965833684826" datatype="html">
|
||||
<source>Correspondents</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">208</context>
|
||||
<context context-type="linenumber">209</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html</context>
|
||||
@@ -3288,7 +3288,7 @@
|
||||
<source>Document types</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">213</context>
|
||||
<context context-type="linenumber">214</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.ts</context>
|
||||
@@ -3299,7 +3299,7 @@
|
||||
<source>Storage paths</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">218</context>
|
||||
<context context-type="linenumber">219</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.ts</context>
|
||||
@@ -3310,7 +3310,7 @@
|
||||
<source>Custom fields</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">223</context>
|
||||
<context context-type="linenumber">224</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
||||
@@ -3329,11 +3329,11 @@
|
||||
<source>Workflows</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">241</context>
|
||||
<context context-type="linenumber">242</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">243</context>
|
||||
<context context-type="linenumber">244</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
||||
@@ -3344,71 +3344,71 @@
|
||||
<source>Mail</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">248</context>
|
||||
<context context-type="linenumber">249</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">251</context>
|
||||
<context context-type="linenumber">252</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7844706011418789951" datatype="html">
|
||||
<source>Administration</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">266</context>
|
||||
<context context-type="linenumber">267</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3008420115644088420" datatype="html">
|
||||
<source>Configuration</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">279</context>
|
||||
<context context-type="linenumber">280</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">281</context>
|
||||
<context context-type="linenumber">282</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1534029177398918729" datatype="html">
|
||||
<source>GitHub</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">326</context>
|
||||
<context context-type="linenumber">327</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4112664765954374539" datatype="html">
|
||||
<source>is available.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">335,336</context>
|
||||
<context context-type="linenumber">336,337</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1175891574282637937" datatype="html">
|
||||
<source>Click to view.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">336</context>
|
||||
<context context-type="linenumber">337</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="9811291095862612" datatype="html">
|
||||
<source>Paperless-ngx can automatically check for updates</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">340</context>
|
||||
<context context-type="linenumber">341</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="894819944961861800" datatype="html">
|
||||
<source> How does this work? </source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">347,349</context>
|
||||
<context context-type="linenumber">348,350</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="509090351011426949" datatype="html">
|
||||
<source>Update available</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
|
||||
<context context-type="linenumber">360</context>
|
||||
<context context-type="linenumber">361</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1542489069631984294" datatype="html">
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-11 23:04+0000\n"
|
||||
"POT-Creation-Date: 2026-07-15 20:44+0000\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@@ -2433,21 +2433,25 @@ msgid "uid"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:342
|
||||
msgid "subject"
|
||||
msgid "uid validity"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:350
|
||||
msgid "subject"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:358
|
||||
msgid "received"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:357
|
||||
#: paperless_mail/models.py:365
|
||||
msgid "processed"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:363
|
||||
#: paperless_mail/models.py:371
|
||||
msgid "status"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:371
|
||||
#: paperless_mail/models.py:379
|
||||
msgid "error"
|
||||
msgstr ""
|
||||
|
||||
@@ -18,6 +18,7 @@ from celery import shared_task
|
||||
from celery.canvas import Signature
|
||||
from django.conf import settings
|
||||
from django.db import DatabaseError
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import is_naive
|
||||
from django.utils.timezone import make_aware
|
||||
@@ -245,6 +246,7 @@ def apply_mail_action(
|
||||
message_uid: str,
|
||||
message_subject: str,
|
||||
message_date: datetime.datetime,
|
||||
uid_validity: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
This shared task applies the mail action of a particular mail rule to the
|
||||
@@ -286,6 +288,7 @@ def apply_mail_action(
|
||||
rule=rule,
|
||||
folder=rule.folder,
|
||||
uid=message_uid,
|
||||
uid_validity=uid_validity,
|
||||
subject=message_subject,
|
||||
received=message_date,
|
||||
status="SUCCESS",
|
||||
@@ -297,6 +300,7 @@ def apply_mail_action(
|
||||
rule=rule,
|
||||
folder=rule.folder,
|
||||
uid=message_uid,
|
||||
uid_validity=uid_validity,
|
||||
subject=message_subject,
|
||||
received=message_date,
|
||||
status="FAILED",
|
||||
@@ -314,6 +318,7 @@ def error_callback(
|
||||
message_uid: str,
|
||||
message_subject: str,
|
||||
message_date: datetime.datetime,
|
||||
uid_validity: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
A shared task that is called whenever something goes wrong during
|
||||
@@ -325,6 +330,7 @@ def error_callback(
|
||||
rule=rule,
|
||||
folder=rule.folder,
|
||||
uid=message_uid,
|
||||
uid_validity=uid_validity,
|
||||
subject=message_subject,
|
||||
received=make_aware(message_date) if is_naive(message_date) else message_date,
|
||||
status="FAILED",
|
||||
@@ -337,6 +343,7 @@ def queue_consumption_tasks(
|
||||
consume_tasks: list[Signature],
|
||||
rule: MailRule,
|
||||
message: MailMessage,
|
||||
uid_validity: str | None,
|
||||
) -> None:
|
||||
"""
|
||||
Queue a list of consumption tasks (Signatures for the consume_file shared
|
||||
@@ -348,6 +355,7 @@ def queue_consumption_tasks(
|
||||
message_uid=message.uid,
|
||||
message_subject=message.subject,
|
||||
message_date=message.date,
|
||||
uid_validity=uid_validity,
|
||||
)
|
||||
chord(header=consume_tasks, body=mail_action_task).on_error(
|
||||
error_callback.s(
|
||||
@@ -355,6 +363,7 @@ def queue_consumption_tasks(
|
||||
message_uid=message.uid,
|
||||
message_subject=message.subject,
|
||||
message_date=message.date,
|
||||
uid_validity=uid_validity,
|
||||
),
|
||||
).delay()
|
||||
|
||||
@@ -460,6 +469,7 @@ class MailAccountHandler(LoggingMixin):
|
||||
super().__init__()
|
||||
self.renew_logging_group()
|
||||
self._init_preprocessors()
|
||||
self._current_uid_validity: str | None = None
|
||||
|
||||
def _init_preprocessors(self) -> None:
|
||||
self._message_preprocessors: list[MailMessagePreprocessor] = []
|
||||
@@ -510,6 +520,21 @@ class MailAccountHandler(LoggingMixin):
|
||||
"Unknown title selector.",
|
||||
) # pragma: no cover
|
||||
|
||||
def _get_uid_validity(self, M: MailBox, folder: str) -> str | None:
|
||||
try:
|
||||
uid_validity = M.folder.status(folder, ["UIDVALIDITY"]).get("UIDVALIDITY")
|
||||
if uid_validity is not None:
|
||||
return str(uid_validity)
|
||||
except errors.MailboxFolderStatusError as e:
|
||||
self.log.warning(
|
||||
f"Server does not support retrieving UIDVALIDITY for folder {folder}: {e}",
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.warning(
|
||||
f"Unable to retrieve UIDVALIDITY for folder {folder}: {e}",
|
||||
)
|
||||
return None
|
||||
|
||||
def _get_correspondent(
|
||||
self,
|
||||
message: MailMessage,
|
||||
@@ -647,6 +672,8 @@ class MailAccountHandler(LoggingMixin):
|
||||
f"does not exist in account {rule.account}",
|
||||
) from err
|
||||
|
||||
self._current_uid_validity = self._get_uid_validity(M, rule.folder)
|
||||
|
||||
criterias = make_criterias(rule, supports_gmail_labels=supports_gmail_labels)
|
||||
|
||||
self.log.debug(
|
||||
@@ -687,11 +714,17 @@ class MailAccountHandler(LoggingMixin):
|
||||
)
|
||||
continue
|
||||
|
||||
if ProcessedMail.objects.filter(
|
||||
already_processed = ProcessedMail.objects.filter(
|
||||
rule=rule,
|
||||
uid=message.uid,
|
||||
folder=rule.folder,
|
||||
).exists():
|
||||
)
|
||||
if self._current_uid_validity is not None:
|
||||
already_processed = already_processed.filter(
|
||||
Q(uid_validity=self._current_uid_validity)
|
||||
| Q(uid_validity__isnull=True),
|
||||
)
|
||||
if already_processed.exists():
|
||||
self.log.debug(
|
||||
f"Skipping mail '{message.uid}' subject '{message.subject}' from '{message.from_}', already processed.",
|
||||
)
|
||||
@@ -921,6 +954,7 @@ class MailAccountHandler(LoggingMixin):
|
||||
consume_tasks=consume_tasks,
|
||||
rule=rule,
|
||||
message=message,
|
||||
uid_validity=self._current_uid_validity,
|
||||
)
|
||||
else:
|
||||
# No files to consume, just mark as processed if it wasn't by .eml processing
|
||||
@@ -928,11 +962,13 @@ class MailAccountHandler(LoggingMixin):
|
||||
rule=rule,
|
||||
uid=message.uid,
|
||||
folder=rule.folder,
|
||||
uid_validity=self._current_uid_validity,
|
||||
).exists():
|
||||
ProcessedMail.objects.create(
|
||||
rule=rule,
|
||||
folder=rule.folder,
|
||||
uid=message.uid,
|
||||
uid_validity=self._current_uid_validity,
|
||||
subject=message.subject,
|
||||
received=make_aware(message.date)
|
||||
if is_naive(message.date)
|
||||
@@ -1009,6 +1045,7 @@ class MailAccountHandler(LoggingMixin):
|
||||
consume_tasks=[consume_task],
|
||||
rule=rule,
|
||||
message=message,
|
||||
uid_validity=self._current_uid_validity,
|
||||
)
|
||||
|
||||
processed_elements = 1
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("paperless_mail", "0003_mailrule_stop_processing"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="processedmail",
|
||||
name="uid_validity",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
editable=False,
|
||||
max_length=64,
|
||||
null=True,
|
||||
verbose_name="uid validity",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -338,6 +338,14 @@ class ProcessedMail(document_models.ModelWithOwner):
|
||||
editable=False,
|
||||
)
|
||||
|
||||
uid_validity = models.CharField(
|
||||
_("uid validity"),
|
||||
null=True,
|
||||
blank=True,
|
||||
max_length=64,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
subject = models.CharField(
|
||||
_("subject"),
|
||||
null=False,
|
||||
|
||||
@@ -54,12 +54,16 @@ class _AttachmentDef:
|
||||
|
||||
class BogusFolderManager:
|
||||
current_folder = "INBOX"
|
||||
uidvalidity = "1"
|
||||
|
||||
def set(self, new_folder) -> None:
|
||||
if new_folder not in ["INBOX", "spam"]:
|
||||
raise MailboxFolderSelectError(None, "uhm")
|
||||
self.current_folder = new_folder
|
||||
|
||||
def status(self, folder, options):
|
||||
return {"UIDVALIDITY": self.uidvalidity}
|
||||
|
||||
|
||||
class BogusClient:
|
||||
def __init__(self, messages) -> None:
|
||||
@@ -944,6 +948,223 @@ class TestMail(
|
||||
|
||||
self.assertEqual(self.mailMocker._queue_consumption_tasks_mock.call_count, 1)
|
||||
|
||||
def test_handle_mail_account_skips_mail_already_processed_in_same_uidvalidity(
|
||||
self,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A ProcessedMail row recorded under the mailbox's current UIDVALIDITY
|
||||
WHEN:
|
||||
- A mail with the same UID is fetched from the same UIDVALIDITY epoch
|
||||
THEN:
|
||||
- The mail is skipped as a duplicate.
|
||||
"""
|
||||
account = MailAccount.objects.create(
|
||||
name="test",
|
||||
imap_server="",
|
||||
username="admin",
|
||||
password="secret",
|
||||
)
|
||||
rule = MailRule.objects.create(
|
||||
name="testrule",
|
||||
account=account,
|
||||
action=MailRule.MailAction.DELETE,
|
||||
)
|
||||
|
||||
message = self.mailMocker.messageBuilder.create_message()
|
||||
self.mailMocker.bogus_mailbox.messages = [message]
|
||||
self.mailMocker.bogus_mailbox.updateClient()
|
||||
|
||||
self.mailMocker.bogus_mailbox.folder.uidvalidity = "SAME"
|
||||
ProcessedMail.objects.create(
|
||||
rule=rule,
|
||||
folder=rule.folder,
|
||||
uid=message.uid,
|
||||
uid_validity="SAME",
|
||||
subject="Previously processed mail",
|
||||
status="SUCCESS",
|
||||
received=timezone.make_aware(timezone.datetime(2023, 1, 1, 12, 0, 0)),
|
||||
)
|
||||
|
||||
self.mail_account_handler.handle_mail_account(account)
|
||||
|
||||
self.assertEqual(self.mailMocker._queue_consumption_tasks_mock.call_count, 0)
|
||||
|
||||
def test_handle_mail_account_processes_mail_after_uidvalidity_change(
|
||||
self,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A ProcessedMail row recorded under a previous UIDVALIDITY epoch
|
||||
WHEN:
|
||||
- A mail with the same UID is fetched after UIDVALIDITY has changed
|
||||
THEN:
|
||||
- The mail is processed, not skipped as a duplicate.
|
||||
"""
|
||||
account = MailAccount.objects.create(
|
||||
name="test",
|
||||
imap_server="",
|
||||
username="admin",
|
||||
password="secret",
|
||||
)
|
||||
rule = MailRule.objects.create(
|
||||
name="testrule",
|
||||
account=account,
|
||||
action=MailRule.MailAction.DELETE,
|
||||
)
|
||||
|
||||
message = self.mailMocker.messageBuilder.create_message()
|
||||
self.mailMocker.bogus_mailbox.messages = [message]
|
||||
self.mailMocker.bogus_mailbox.updateClient()
|
||||
|
||||
self.mailMocker.bogus_mailbox.folder.uidvalidity = "NEW"
|
||||
ProcessedMail.objects.create(
|
||||
rule=rule,
|
||||
folder=rule.folder,
|
||||
uid=message.uid,
|
||||
uid_validity="OLD",
|
||||
subject="Previously processed mail",
|
||||
status="SUCCESS",
|
||||
received=timezone.make_aware(timezone.datetime(2023, 1, 1, 12, 0, 0)),
|
||||
)
|
||||
|
||||
self.mail_account_handler.handle_mail_account(account)
|
||||
|
||||
self.assertEqual(self.mailMocker._queue_consumption_tasks_mock.call_count, 1)
|
||||
|
||||
def test_handle_mail_account_skips_mail_processed_before_uidvalidity_tracking(
|
||||
self,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A ProcessedMail row recorded before UIDVALIDITY tracking existed
|
||||
(uid_validity is NULL)
|
||||
WHEN:
|
||||
- A mail with the same UID is fetched
|
||||
THEN:
|
||||
- The mail is skipped as a duplicate, to avoid re-ingesting all
|
||||
previously processed mail after upgrading.
|
||||
"""
|
||||
account = MailAccount.objects.create(
|
||||
name="test",
|
||||
imap_server="",
|
||||
username="admin",
|
||||
password="secret",
|
||||
)
|
||||
rule = MailRule.objects.create(
|
||||
name="testrule",
|
||||
account=account,
|
||||
action=MailRule.MailAction.DELETE,
|
||||
)
|
||||
|
||||
message = self.mailMocker.messageBuilder.create_message()
|
||||
self.mailMocker.bogus_mailbox.messages = [message]
|
||||
self.mailMocker.bogus_mailbox.updateClient()
|
||||
|
||||
ProcessedMail.objects.create(
|
||||
rule=rule,
|
||||
folder=rule.folder,
|
||||
uid=message.uid,
|
||||
uid_validity=None,
|
||||
subject="Previously processed mail",
|
||||
status="SUCCESS",
|
||||
received=timezone.make_aware(timezone.datetime(2023, 1, 1, 12, 0, 0)),
|
||||
)
|
||||
|
||||
self.mail_account_handler.handle_mail_account(account)
|
||||
|
||||
self.assertEqual(self.mailMocker._queue_consumption_tasks_mock.call_count, 0)
|
||||
|
||||
def test_handle_mail_account_processes_mail_when_uidvalidity_unavailable(
|
||||
self,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- The mail server fails to report a UIDVALIDITY for the folder
|
||||
WHEN:
|
||||
- A mail account is processed
|
||||
THEN:
|
||||
- The failure is logged and the rule still processes the mail,
|
||||
instead of the whole rule being disabled.
|
||||
"""
|
||||
account = MailAccount.objects.create(
|
||||
name="test",
|
||||
imap_server="",
|
||||
username="admin",
|
||||
password="secret",
|
||||
)
|
||||
_ = MailRule.objects.create(
|
||||
name="testrule",
|
||||
account=account,
|
||||
action=MailRule.MailAction.DELETE,
|
||||
)
|
||||
|
||||
message = self.mailMocker.messageBuilder.create_message()
|
||||
self.mailMocker.bogus_mailbox.messages = [message]
|
||||
self.mailMocker.bogus_mailbox.updateClient()
|
||||
|
||||
self.mailMocker.bogus_mailbox.folder.status = mock.MagicMock(
|
||||
side_effect=errors.MailboxFolderStatusError(("NO", [b"unsupported"]), "OK"),
|
||||
)
|
||||
|
||||
with self.assertLogs("paperless_mail", level="WARNING") as cm:
|
||||
self.mail_account_handler.handle_mail_account(account)
|
||||
|
||||
self.assertEqual(self.mailMocker._queue_consumption_tasks_mock.call_count, 1)
|
||||
self.assertEqual(len(cm.output), 1)
|
||||
self.assertIn(
|
||||
"Server does not support retrieving UIDVALIDITY",
|
||||
cm.output[0],
|
||||
)
|
||||
|
||||
def test_handle_mail_account_skips_mail_when_uidvalidity_unavailable_but_prior_record_exists(
|
||||
self,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A ProcessedMail row recorded with a real uid_validity value
|
||||
- The mail server fails to report UIDVALIDITY (MailboxFolderStatusError),
|
||||
so _get_uid_validity returns None
|
||||
WHEN:
|
||||
- A mail with the same UID is fetched
|
||||
THEN:
|
||||
- The mail is skipped as already-processed rather than re-ingested,
|
||||
falling back to (rule, uid, folder) matching.
|
||||
"""
|
||||
account = MailAccount.objects.create(
|
||||
name="test",
|
||||
imap_server="",
|
||||
username="admin",
|
||||
password="secret",
|
||||
)
|
||||
rule = MailRule.objects.create(
|
||||
name="testrule",
|
||||
account=account,
|
||||
action=MailRule.MailAction.DELETE,
|
||||
)
|
||||
|
||||
message = self.mailMocker.messageBuilder.create_message()
|
||||
self.mailMocker.bogus_mailbox.messages = [message]
|
||||
self.mailMocker.bogus_mailbox.updateClient()
|
||||
|
||||
ProcessedMail.objects.create(
|
||||
rule=rule,
|
||||
folder=rule.folder,
|
||||
uid=message.uid,
|
||||
uid_validity="REAL_VALIDITY",
|
||||
subject="Previously processed mail",
|
||||
status="SUCCESS",
|
||||
received=timezone.make_aware(timezone.datetime(2023, 1, 1, 12, 0, 0)),
|
||||
)
|
||||
|
||||
self.mailMocker.bogus_mailbox.folder.status = mock.MagicMock(
|
||||
side_effect=errors.MailboxFolderStatusError(("NO", [b"unsupported"]), "OK"),
|
||||
)
|
||||
|
||||
self.mail_account_handler.handle_mail_account(account)
|
||||
|
||||
self.assertEqual(self.mailMocker._queue_consumption_tasks_mock.call_count, 0)
|
||||
|
||||
@pytest.mark.flaky(reruns=4)
|
||||
def test_handle_mail_account_flag(self) -> None:
|
||||
account = MailAccount.objects.create(
|
||||
|
||||
Reference in New Issue
Block a user