Skip to content

X-GM-EXT-1 Gmail Label Extension for Proton Bridge#450

Open
bolausson wants to merge 6 commits intoProtonMail:devfrom
bolausson:dev
Open

X-GM-EXT-1 Gmail Label Extension for Proton Bridge#450
bolausson wants to merge 6 commits intoProtonMail:devfrom
bolausson:dev

Conversation

@bolausson
Copy link

@bolausson bolausson commented Feb 22, 2026

Just to be clear - This was entirly coded with a LLM!

X-GM-EXT-1 Gmail Label Extension for Proton Bridge

Paperless-NGX uses Gmail's X-GM-LABELS IMAP extension to apply and query labels on emails after processing. Proton Bridge didn't support this, so we implemented it across Gluon and Bridge.

Gluon (IMAP server library)

  • STORE X-GM-LABELS — Parses STORE +X-GM-LABELS ("Paperless") / -X-GM-LABELS commands, routes them through a new SetGmailLabels connector interface to the Bridge backend. Added X-GM-EXT-1 capability advertisement.
  • FETCH X-GM-LABELS — Parses FETCH (X-GM-LABELS) as a fetch attribute, calls GetGmailLabels on the connector, returns labels in Gmail format: X-GM-LABELS ("Paperless" "Notifications").
  • SEARCH X-GM-LABELS — Parses X-GM-LABELS as a search key so clients can filter messages by label (e.g. NOT X-GM-LABELS "Paperless"). The search operation calls GetGmailLabels on the connector for each candidate message with case-insensitive matching.
  • Pre-auth capability fix — Moved X-GM-EXT-1 to the pre-auth capability list. Python's imaplib checks capabilities before LOGIN and never re-reads them, so clients like Paperless-NGX weren't detecting support.

Proton Bridge

  • STORE backendMarkMessagesWithGmailLabels maps label names to Proton label IDs (auto-creating if needed), then calls LabelMessages/UnlabelMessages API. Messages stay in INBOX — labels are applied without moving.
  • FETCH/SEARCH backendGetGmailLabels calls GetMessage API to get LabelIDs, maps them back to names via the shared label cache, filtering for LabelTypeLabel only. Used by both FETCH and SEARCH operations.

Tests

telnet localhost 1143
a LOGIN <some@example.com> <password>
a LIST "" "Labels/%"
a SELECT "INBOX"
a UID SEARCH UNSEEN
a UID FETCH <UID> (BODY.PEEK[HEADER.FIELDS (SUBJECT)])
a UID FETCH <UID> (X-GM-LABELS)
a UID STORE <UID> +X-GM-LABELS ("__paperless_test_label__")
a UID FETCH <UID> (X-GM-LABELS)
a UID SEARCH X-GM-LABELS "__paperless_test_label__"
# a UID SEARCH NOT X-GM-LABELS "__paperless_test_label__"
a SELECT "Labels/__paperless_test_label__"
a UID SEARCH UNSEEN
a SELECT "INBOX"
a UID STORE <UID> -X-GM-LABELS ("__paperless_test_label__")
a UID FETCH <UID> (X-GM-LABELS)
a DELETE "Labels/__paperless_test_label__"
a LIST "" "Labels/%"
a LOGOUT
$ telnet localhost 1143
Trying ::1...
Connected to localhost.
Escape character is '^]'.
* OK [CAPABILITY AUTH=PLAIN ID IDLE IMAP4rev1 MOVE STARTTLS UIDPLUS UNSELECT X-GM-EXT-1] Proton Mail Bridge 03.22.00 - gluon session ID 2
a LOGIN <some@example.com> <password>
a OK [CAPABILITY AUTH=PLAIN ID IDLE IMAP4rev1 MOVE STARTTLS UIDPLUS UNSELECT X-GM-EXT-1] Logged in
a LIST "" "Labels/%"
* LIST (\Unmarked) "/" "Labels/Follow up"
* LIST (\Marked) "/" "Labels/Important"
a OK LIST
a SELECT "INBOX"
* FLAGS ($Forwarded Forwarded \Deleted \Flagged \Seen)
* 26487 EXISTS
* 0 RECENT
* OK [PERMANENTFLAGS ($Forwarded Forwarded \Deleted \Flagged \Seen)] Flags permitted
* OK [UIDNEXT 26545] Predicted next UID
* OK [UIDVALIDITY 96066389] UIDs valid
* OK [UNSEEN 26486] Unseen messages
a OK [READ-WRITE] SELECT
a UID SEARCH UNSEEN
* SEARCH 26543 26544
a OK command completed in 19322 microsec.
a UID FETCH 26543 (BODY.PEEK[HEADER.FIELDS (SUBJECT)])
* 26486 FETCH (BODY[HEADER.FIELDS (SUBJECT)] {59}
Subject: Proton Bridge with X-GM-EXT-1 support - Test 4

 UID 26543)
* 26487 FETCH (FLAGS (\Seen))
a OK [EXPUNGEISSUED] command completed in 1382 microsec.
a UID FETCH 26543 (X-GM-LABELS)
* 26486 FETCH (X-GM-LABELS ("Bjoern") UID 26543)
a OK [EXPUNGEISSUED] command completed in 311935 microsec.
a UID STORE 26543 +X-GM-LABELS ("__paperless_test_label__")
a OK command completed in 679973 microsec.
a UID FETCH 26543 (X-GM-LABELS)
* 26486 FETCH (X-GM-LABELS ("Bjoern" "__paperless_test_label__") UID 26543)
a OK [EXPUNGEISSUED] command completed in 321493 microsec.
a UID SEARCH X-GM-LABELS "__paperless_test_label__"
* SEARCH 26543
a OK [EXPUNGEISSUED] command completed in 18271 microsec.
a SELECT "Labels/__paperless_test_label__"
* FLAGS ($Forwarded Forwarded \Deleted \Flagged \Seen)
* 1 EXISTS
* 1 RECENT
* OK [PERMANENTFLAGS ($Forwarded Forwarded \Deleted \Flagged \Seen)] Flags permitted
* OK [UIDNEXT 2] Predicted next UID
* OK [UIDVALIDITY 96519480] UIDs valid
* OK [UNSEEN 1] Unseen messages
a OK [READ-WRITE] SELECT
a UID SEARCH UNSEEN
* SEARCH 1
a OK command completed in 264 microsec.
a SELECT "INBOX"
* FLAGS ($Forwarded Forwarded \Deleted \Flagged \Seen)
* 26486 EXISTS
* 0 RECENT
* OK [PERMANENTFLAGS ($Forwarded Forwarded \Deleted \Flagged \Seen)] Flags permitted
* OK [UIDNEXT 26545] Predicted next UID
* OK [UIDVALIDITY 96066389] UIDs valid
* OK [UNSEEN 26486] Unseen messages
a OK [READ-WRITE] SELECT
a UID STORE 26543 -X-GM-LABELS ("__paperless_test_label__")
a OK command completed in 400725 microsec.
a UID FETCH 26543 (X-GM-LABELS)
* 26486 FETCH (X-GM-LABELS ("Bjoern") UID 26543)
a OK command completed in 284999 microsec.
a DELETE "Labels/__paperless_test_label__"
a OK DELETE
a LIST "" "Labels/%"
* LIST (\Unmarked) "/" "Labels/Follow up"
* LIST (\Marked) "/" "Labels/Important"
a OK LIST
a LOGOUT
* BYE
a OK LOGOUT
Connection closed by foreign host.

Paperless-ngx logs:

paperless.log

[2026-02-22 04:21:44,001] [DEBUG] [paperless.tasks] Executing plugin ConsumerPreflightPlugin
[2026-02-22 04:21:44,014] [INFO] [paperless.tasks] ConsumerPreflightPlugin completed with no message
[2026-02-22 04:21:44,014] [DEBUG] [paperless.tasks] Skipping plugin CollatePlugin
[2026-02-22 04:21:44,017] [DEBUG] [paperless.tasks] Skipping plugin BarcodePlugin
[2026-02-22 04:21:44,017] [DEBUG] [paperless.tasks] Executing plugin WorkflowTriggerPlugin
[2026-02-22 04:21:44,101] [INFO] [paperless.matching] Document did not match Workflow: Import-Bjoern
[2026-02-22 04:21:44,101] [DEBUG] [paperless.matching] ("Document source MailFetch not in ['ApiUpload']",)
[2026-02-22 04:21:44,101] [INFO] [paperless.matching] Document matched WorkflowTrigger 3 from Workflow: Import-Bjoern
[2026-02-22 04:21:44,149] [INFO] [paperless.matching] Document did not match Workflow: Import-Other
[2026-02-22 04:21:44,150] [DEBUG] [paperless.matching] ('Document path /tmp/paperless/paperless-mail-h3aukvuh/Support_Case_Analysis_Report.pdf does not match */consume/Other/*',)
[2026-02-22 04:21:44,150] [INFO] [paperless.tasks] WorkflowTriggerPlugin completed with: Applying WorkflowAction 1 from Workflow: Import-Bjoern
[2026-02-22 04:21:44,150] [DEBUG] [paperless.tasks] Executing plugin ConsumeTaskPlugin
[2026-02-22 04:21:44,150] [INFO] [paperless.consumer] Consuming Support_Case_Analysis_Report.pdf
[2026-02-22 04:21:44,152] [DEBUG] [paperless.consumer] Detected mime type: application/pdf
[2026-02-22 04:21:44,160] [DEBUG] [paperless.consumer] Parser: RasterisedDocumentParser
[2026-02-22 04:21:44,165] [DEBUG] [paperless.consumer] Parsing Support_Case_Analysis_Report.pdf...
[2026-02-22 04:21:44,213] [INFO] [paperless.parsing.tesseract] pdftotext exited 0
[2026-02-22 04:21:44,587] [DEBUG] [paperless.parsing.tesseract] Calling OCRmyPDF with args: {'input_file': PosixPath('/tmp/paperless/paperless-ngxjzpdrcty/Support_Case_Analysis_Report.pdf'), 'output_file': PosixPath('/tmp/paperless/paperless-xw3sekya/archive.pdf'), 'use_threads': True, 'jobs': 4, 'language': 'deu', 'output_type': 'pdfa', 'progress_bar': False, 'color_conversion_strategy': 'RGB', 'skip_text': True, 'clean': True, 'deskew': True, 'rotate_pages': True, 'rotate_pages_threshold': 12.0, 'sidecar': PosixPath('/tmp/paperless/paperless-xw3sekya/sidecar.txt'), 'invalidate_digital_signatures': True}
[2026-02-22 04:21:44,935] [INFO] [ocrmypdf._pipelines.ocr] Start processing 2 pages concurrently
[2026-02-22 04:21:44,936] [INFO] [ocrmypdf._pipeline] skipping all processing on this page
[2026-02-22 04:21:44,936] [INFO] [ocrmypdf._pipeline] skipping all processing on this page
[2026-02-22 04:21:44,938] [INFO] [ocrmypdf._pipelines.ocr] Postprocessing...
[2026-02-22 04:21:45,223] [INFO] [ocrmypdf._pipeline] Image optimization ratio: 1.00 savings: 0.0%
[2026-02-22 04:21:45,223] [INFO] [ocrmypdf._pipeline] Total file size ratio: 0.05 savings: -2059.4%
[2026-02-22 04:21:45,225] [INFO] [ocrmypdf._pipelines._common] Output file is a PDF/A-2b (as expected)
[2026-02-22 04:21:45,230] [DEBUG] [paperless.parsing.tesseract] Incomplete sidecar file: discarding.
[2026-02-22 04:21:45,307] [INFO] [paperless.parsing.tesseract] pdftotext exited 0
[2026-02-22 04:21:45,308] [DEBUG] [paperless.consumer] Generating thumbnail for Support_Case_Analysis_Report.pdf...
[2026-02-22 04:21:45,312] [DEBUG] [paperless.parsing] Execute: convert -density 300 -scale 500x5000> -alpha remove -strip -auto-orient -define pdf:use-cropbox=true /tmp/paperless/paperless-xw3sekya/archive.pdf[0] /tmp/paperless/paperless-xw3sekya/convert.webp
[2026-02-22 04:21:46,736] [INFO] [paperless.parsing] convert exited 0
[2026-02-22 04:21:50,063] [DEBUG] [paperless.consumer] Saving record to database
[2026-02-22 04:21:50,063] [DEBUG] [paperless.consumer] Creation date from st_mtime: 2026-02-22 04:21:43.368249+01:00
[2026-02-22 04:21:50,065] [DEBUG] [paperless.templating] Parsing Workflow Jinja template: Support_Case_Analysis_Report
[2026-02-22 04:21:51,333] [INFO] [paperless.handlers] Assigning correspondent Sonstige to 2026-02-22T04:21:43.368249+01:00 Support_Case_Analysis_Report
[2026-02-22 04:21:51,346] [INFO] [paperless.handlers] Assigning document type Information to 2026-02-22T04:21:43.368249+01:00 Sonstige Support_Case_Analysis_Report
[2026-02-22 04:21:51,365] [INFO] [paperless.handlers] Tagging "2026-02-22T04:21:43.368249+01:00 Sonstige Support_Case_Analysis_Report" with "Depot, Versicherung"
[2026-02-22 04:21:51,496] [DEBUG] [paperless.index] Index updated for document 7160.
[2026-02-22 04:21:51,733] [DEBUG] [paperless.consumer] Deleting original file /tmp/paperless/paperless-mail-h3aukvuh/Support_Case_Analysis_Report.pdf
[2026-02-22 04:21:51,734] [DEBUG] [paperless.consumer] Deleting working copy /tmp/paperless/paperless-ngxjzpdrcty/Support_Case_Analysis_Report.pdf
[2026-02-22 04:21:51,740] [DEBUG] [paperless.parsing.tesseract] Deleting directory /tmp/paperless/paperless-xw3sekya
[2026-02-22 04:21:51,741] [INFO] [paperless.consumer] Document 2026-02-22 Sonstige Support_Case_Analysis_Report consumption finished
[2026-02-22 04:21:51,746] [INFO] [paperless.tasks] ConsumeTaskPlugin completed with: Success. New document id 7160 created

mail.log

[2026-02-22 04:21:40,536] [DEBUG] [paperless_mail] Skipping mail preprocessor MailMessageDecryptor
[2026-02-22 04:21:40,536] [DEBUG] [paperless_mail] Processing mail account Proton
[2026-02-22 04:21:40,601] [DEBUG] [paperless_mail] GMAIL Label Support: True
[2026-02-22 04:21:40,602] [DEBUG] [paperless_mail] AUTH=PLAIN Support: True
[2026-02-22 04:21:41,069] [DEBUG] [paperless_mail] Account Proton: Processing 1 rule(s)
[2026-02-22 04:21:41,072] [DEBUG] [paperless_mail] Rule Proton.Bjoern-PDF: Selecting folder INBOX
[2026-02-22 04:21:41,536] [DEBUG] [paperless_mail] Rule Proton.Bjoern-PDF: Searching folder with criteria ((NOT (X-GM-LABELS "Paperless") UNKEYWORD Paperless) SINCE 23-Jan-2026)
[2026-02-22 04:21:43,363] [DEBUG] [paperless_mail] Rule Proton.Bjoern-PDF: Processing mail Fwd: Proton Bridge with X-GM-EXT-1 support - Test 4 from <mail>@gmail.com with 1 attachment(s)
[2026-02-22 04:21:43,371] [INFO] [paperless_mail] Rule Proton.Bjoern-PDF: Consuming attachment Support_Case_Analysis_Report.pdf from mail Fwd: Proton Bridge with X-GM-EXT-1 support - Test 4 from <mail>@gmail.com
[2026-02-22 04:21:43,415] [DEBUG] [paperless_mail] Rule Proton.Bjoern-PDF: Processed 117 matching mail(s)

celery.log

[2026-02-22 04:21:43,416] [INFO] [celery.worker.strategy] Task documents.tasks.consume_file[ecaf3755-6e0c-4675-b481-4a7b9100368a] received
[2026-02-22 04:21:43,429] [INFO] [celery.app.trace] Task paperless_mail.tasks.process_mail_accounts[576a8f56-0d12-4d7f-beef-c4b5d6e593fd] succeeded in 2.9539029439911246s: 'Added 1 document(s).'
[2026-02-22 04:21:43,455] [DEBUG] [celery.pool] TaskPool: Apply <function fast_trace_task at 0xffff8fc1fec0> (args:('documents.tasks.consume_file', 'ecaf3755-6e0c-4675-b481-4a7b9100368a', {'lang': 'py', 'task': 'documents.tasks.consume_file', 'id': 'ecaf3755-6e0c-4675-b481-4a7b9100368a', 'shadow': None, 'eta': None, 'expires': None, 'group': 'ce2b51ce-2378-486a-b713-a485338694e0', 'group_index': 0, 'retries': 0, 'timelimit': [None, None], 'root_id': '576a8f56-0d12-4d7f-beef-c4b5d6e593fd', 'parent_id': '576a8f56-0d12-4d7f-beef-c4b5d6e593fd', 'argsrepr': "(ConsumableDocument(source=<DocumentSource.MailFetch: 3>, original_file=PosixPath('/tmp/paperless/paperless-mail-h3aukvuh/Support_Case_Analysis_Report.pdf'), original_path=None, mailrule_id=5, mime_type='application/pdf'), DocumentMetadataOverrides(filename='Support_Case_Analysis_Report.pdf', title='Support_Case_Analysis_Report', correspondent_id=None, document_type_id=None, tag_ids=[23, 1], storage_path_id=None, created=None, asn=None, owner_id=3, view_users=None, view_groups=None, change_users=None, change_groups=None, custom_fields=None))", 'kwargsrepr': '{}',... kwargs:{})
[2026-02-22 04:21:51,770] [INFO] [celery.app.trace] Task documents.tasks.consume_file[ecaf3755-6e0c-4675-b481-4a7b9100368a] succeeded in 7.831161381996935s: 'Success. New document id 7160 created'
[2026-02-22 04:21:51,772] [INFO] [celery.worker.strategy] Task paperless_mail.mail.apply_mail_action[9a3c9d52-dca5-4202-a7bd-df514e19c837] received
[2026-02-22 04:21:51,773] [DEBUG] [celery.pool] TaskPool: Apply <function fast_trace_task at 0xffff8fc1fec0> (args:('paperless_mail.mail.apply_mail_action', '9a3c9d52-dca5-4202-a7bd-df514e19c837', {'lang': 'py', 'task': 'paperless_mail.mail.apply_mail_action', 'id': '9a3c9d52-dca5-4202-a7bd-df514e19c837', 'shadow': None, 'eta': None, 'expires': None, 'group': None, 'group_index': None, 'retries': 0, 'timelimit': [None, None], 'root_id': '576a8f56-0d12-4d7f-beef-c4b5d6e593fd', 'parent_id': 'ecaf3755-6e0c-4675-b481-4a7b9100368a', 'argsrepr': "(['Success. New document id 7160 created'],)", 'kwargsrepr': "{'rule_id': 5, 'message_uid': '26546', 'message_subject': 'Fwd: Proton Bridge with X-GM-EXT-1 support - Test 4', 'message_date': datetime.datetime(2026, 2, 22, 4, 21, 9, tzinfo=datetime.timezone(datetime.timedelta(seconds=3600)))}", 'origin': 'gen3817@edd8936e3312', 'ignore_result': False, 'replaced_task_nesting': 0, 'stamped_headers': None, 'stamps': {}, 'properties': {'correlation_id': '9a3c9d52-dca5-4202-a7bd-df514e19c837', 'reply_to': '31e8b107-1a2b-308d-af48-f397b6cd116d', 'delivery_mode': 2, 'delivery_info':... kwargs:{})
[2026-02-22 04:21:54,379] [INFO] [celery.app.trace] Task paperless_mail.mail.apply_mail_action[9a3c9d52-dca5-4202-a7bd-df514e19c837] succeeded in 1.243677235004725s: None
image

B. Olausson and others added 6 commits February 21, 2026 23:48
Implement the X-GM-EXT-1 IMAP extension to support Gmail-style label
operations via STORE X-GM-LABELS commands. This allows IMAP clients like
Paperless-NGX to apply/remove labels without moving messages between
folders.

Changes:
- Advertise X-GM-EXT-1 capability in IMAP sessions
- Extend STORE command parser to handle X-GM-LABELS data item
- Add StoreGmailLabels method to state Mailbox
- Add SetGmailLabels to connector interface
- Route Gmail label STORE through connector to backend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extend the FETCH command to recognize X-GM-LABELS as a fetch attribute,
allowing IMAP clients to read Gmail-style labels on messages. This
complements the existing STORE X-GM-LABELS support.

Changes:
- Parse X-GM-LABELS in FETCH attribute handler
- Add GetGmailLabels to connector interface for label retrieval
- Format response as X-GM-LABELS ("Label1" "Label2")

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Python's imaplib sends CAPABILITY before LOGIN and never re-reads
capabilities after authentication. Moving X-GM-EXT-1 to the pre-auth
capability list so clients like paperless-ngx can detect Gmail label
support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Parse X-GM-LABELS as a search key so clients like paperless-ngx can
filter messages by Gmail-style label (e.g. NOT X-GM-LABELS "Paperless").
The search operation calls GetGmailLabels on the connector for each
candidate message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace per-message Proton API calls with local DB queries. The search
now resolves the label to a mailbox ID via the connector, queries the
local DB for all messages in that mailbox, and builds an in-memory set
for O(1) per-message lookups. This turns N API calls into 2 local
queries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Python's imaplib sends labels without wrapping in parentheses:
  UID STORE 123 +X-GM-LABELS Paperless
The parser previously required parenthesized form only:
  UID STORE 123 +X-GM-LABELS (Paperless)
Now both forms are accepted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Mullayam
Copy link

@bolausson any plan to add QUOTA COMMAND

@bolausson
Copy link
Author

@bolausson any plan to add QUOTA COMMAND

I don't see why paperless-ngx would need that.

The sole purpose of this patch is to make the bride compatible with the way paperless-ngx tags (applies labels) to eMails.

Cheers,
Bjoern

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants