Skip to content

Commit ff310a6

Browse files
committed
✨(client-bridge) add new IMAP/SMTP proxy for legacy clients (!)
1 parent 5b89cf5 commit ff310a6

57 files changed

Lines changed: 6702 additions & 88 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Makefile

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ create-env-files: \
6161
env.d/development/frontend.local \
6262
env.d/development/mta-in.local \
6363
env.d/development/mta-out.local \
64+
env.d/development/client-bridge.local \
6465
env.d/development/socks-proxy.local
6566
.PHONY: create-env-files
6667

@@ -178,7 +179,8 @@ lint: \
178179
lint-front \
179180
typecheck-front \
180181
lint-mta-in \
181-
lint-mta-out
182+
lint-mta-out \
183+
lint-client-bridge
182184
.PHONY: lint
183185

184186
lint-check: ## run all linters in check mode (no auto-fix)
@@ -231,6 +233,10 @@ lint-mta-out: ## lint mta-out python sources
231233
$(COMPOSE_RUN) --rm -e EXEC_CMD_ONLY=true mta-out-test ruff format .
232234
.PHONY: lint-mta-out
233235

236+
lint-client-bridge: ## lint client-bridge python sources
237+
$(COMPOSE_RUN) --rm -e EXEC_CMD_ONLY=true client-bridge-test ruff format .
238+
.PHONY: lint-client-bridge
239+
234240
# -- Tests
235241

236242
test: ## run all tests
@@ -239,6 +245,7 @@ test: \
239245
test-front \
240246
test-mta-in \
241247
test-mta-out \
248+
test-client-bridge \
242249
test-mpa \
243250
test-socks-proxy
244251
.PHONY: test
@@ -280,6 +287,10 @@ test-mta-out: ## run the mta-out tests
280287
@$(COMPOSE) run --build --rm mta-out-test
281288
.PHONY: test-mta-out
282289

290+
test-client-bridge: ## run the client-bridge tests
291+
@$(COMPOSE) run --build --rm client-bridge-test
292+
.PHONY: test-client-bridge
293+
283294
test-mpa: ## run the mpa tests
284295
@$(COMPOSE) run --build --rm mpa-test
285296
.PHONY: test-mpa
@@ -578,3 +589,7 @@ deps-lock-mta-in: ## lock the dependencies
578589
deps-lock-mta-out: ## lock the dependencies
579590
@$(COMPOSE) run --rm --build mta-out-uv uv lock
580591
.PHONY: deps-lock-mta-out
592+
593+
deps-lock-client-bridge: ## lock the dependencies
594+
@$(COMPOSE) run --rm --build client-bridge-uv uv lock
595+
.PHONY: deps-lock-client-bridge

README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,11 @@ It features a [MTA](https://en.wikipedia.org/wiki/Message_transfer_agent) based
5858
* (soon) 👉 Assign threads to specific users
5959

6060
### Based on standards
61-
* 🔑 OpenID Connect for all user accounts. Plug any identity provider, including Keycloak.
62-
* 📬 SMTP in and out.
63-
* ❌ No POP3 or IMAP client support, by design. We're building for the future, not the (unsecure) past!
64-
* ✅ JMAP-inspired data model. Full support could be added.
61+
* 🔑 OpenID Connect for all user accounts as the primary authentication method. Plug any identity provider, including Keycloak.
62+
* 📬 SMTP in and out (server-to-server).
63+
* ✅ JMAP-inspired data model. JMAP-compilant endpoint [in progress](https://github.com/suitenumerique/messages/pull/479).
64+
* 📮 Optional IMAP and SMTP client access via the [client bridge](/src/client-bridge/), for users who prefer traditional email clients like Thunderbird or mobile phones. Uses app-specific passwords with configurable roles.
65+
6566

6667
### Self-host
6768
* 🚀 Messages is designed to be installed on the cloud or on your own servers.
@@ -146,6 +147,8 @@ When running the project, the following services are available:
146147
| **SOCKS Proxy** | 8916 | SOCKS5 proxy | `user1` / `pwd1` |
147148
| **Mailcatcher (SMTP)** | 8917 | SMTP server | No auth required |
148149
| **MPA (Rspamd)** | 8918 | Spam filtering service | `password` |
150+
| **Client Bridge (IMAP)** | 8919 | IMAP server for email clients | App-specific password |
151+
| **Client Bridge (SMTP)** | 8920 | SMTP submission for email clients | App-specific password |
149152

150153

151154
### OpenAPI client

compose.yaml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,49 @@ services:
268268
target: uv
269269
pull_policy: build
270270

271+
client-bridge:
272+
build:
273+
context: src/client-bridge
274+
target: runtime-prod
275+
env_file:
276+
- env.d/development/client-bridge.defaults
277+
- env.d/development/client-bridge.local
278+
ports:
279+
- "8919:143"
280+
- "8920:587"
281+
depends_on:
282+
- backend-dev
283+
284+
client-bridge-test:
285+
profiles:
286+
- tools
287+
build:
288+
context: src/client-bridge
289+
target: runtime-dev
290+
env_file:
291+
- env.d/development/client-bridge.defaults
292+
- env.d/development/client-bridge.local
293+
environment:
294+
- EXEC_CMD=true
295+
- IMAP_HOST=localhost
296+
- IMAP_PORT=1143
297+
- SMTP_HOST=localhost
298+
- SMTP_PORT=1587
299+
- MOCK_API_PORT=8765
300+
command: pytest -vvs tests/
301+
volumes:
302+
- ./src/client-bridge:/app
303+
304+
client-bridge-uv:
305+
profiles:
306+
- tools
307+
volumes:
308+
- ./src/client-bridge:/app
309+
build:
310+
context: src/client-bridge
311+
target: uv
312+
pull_policy: build
313+
271314
mta-out:
272315
build:
273316
context: src/mta-out

env.d/development/backend.defaults

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ AI_MODEL=
100100
FEATURE_AI_SUMMARY=False
101101
FEATURE_AI_AUTOLABELS=False
102102

103+
# Client bridge (IMAP/SMTP email client access)
104+
FEATURE_CLIENTBRIDGE=False
105+
103106
# Third-party services
104107
# Drive - https://github.com/suitenumerique/drive
105108
DRIVE_BASE_URL=
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
MESSAGES_API_BASE_URL=http://backend-dev:8000/api/v1.0/
2+
CLIENTBRIDGE_API_SECRET=my-shared-secret-clientbridge-at-least-32-bytes
3+
ENABLE_IMAP=true
4+
IMAP_HOST=0.0.0.0
5+
IMAP_PORT=143
6+
ENABLE_SMTP=true
7+
SMTP_HOST=0.0.0.0
8+
SMTP_PORT=587

src/backend/.pylintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs=0
2323

2424
# List of plugins (as comma separated values of python modules names) to load,
2525
# usually to register additional checkers.
26-
load-plugins=pylint_django
26+
load-plugins=pylint_django,pylint_custom
2727

2828
# Pickle collected data for later comparisons.
2929
persistent=yes

src/backend/README.md

Lines changed: 0 additions & 1 deletion
This file was deleted.

src/backend/core/api/openapi.json

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,34 @@
124124
}
125125
}
126126
},
127+
"/api/v1.0/client-bridge/auth/": {
128+
"post": {
129+
"operationId": "client_bridge_auth_create",
130+
"description": "Authenticate a client-bridge channel by email username and app-specific password.\n\nPOST /api/v1.0/client-bridge/auth/\nInput: {\"username\": \"<mailbox email>\", \"password\": \"...\"}\nReturns: {\"token\": \"<JWT>\", \"channel_id\", \"mailbox_id\", \"mailbox_email\", \"role\",\n \"expires_at\": \"<ISO 8601>\"}\n\nThe request must carry ``X-Service-Auth: Bearer <CLIENTBRIDGE_API_SECRET>``\nto prove it comes from the client-bridge service.\n\nThe JWT is signed with CLIENTBRIDGE_API_SECRET and contains the channel_id,\nmailbox_id, role, and expiration. The client-bridge passes it as\nX-Channel-Token on all subsequent requests.",
131+
"tags": [
132+
"client-bridge"
133+
],
134+
"responses": {
135+
"200": {
136+
"description": "No response body"
137+
}
138+
}
139+
}
140+
},
141+
"/api/v1.0/client-bridge/submit/": {
142+
"post": {
143+
"operationId": "client_bridge_submit_create",
144+
"description": "Submit an outbound message via the client-bridge.\n\nPOST /api/v1.0/client-bridge/submit/\nContent-Type: message/rfc822 (raw email)\nHeaders: X-Channel-Token (JWT), X-Mail-From, X-Rcpt-To\nReturns: {\"message_id\": \"...\", \"status\": \"accepted\"}\n\nUses ClientBridgeChannelAuthentication: the channel is resolved from\nthe JWT and available as request.auth.",
145+
"tags": [
146+
"client-bridge"
147+
],
148+
"responses": {
149+
"200": {
150+
"description": "No response body"
151+
}
152+
}
153+
}
154+
},
127155
"/api/v1.0/config/": {
128156
"get": {
129157
"operationId": "config_retrieve",
@@ -2818,6 +2846,50 @@
28182846
}
28192847
}
28202848
},
2849+
"/api/v1.0/mailboxes/{mailbox_id}/channels/{id}/rotate-password/": {
2850+
"post": {
2851+
"operationId": "mailboxes_channels_rotate_password_create",
2852+
"description": "Manage integration channels for a mailbox",
2853+
"parameters": [
2854+
{
2855+
"in": "path",
2856+
"name": "id",
2857+
"schema": {
2858+
"type": "string"
2859+
},
2860+
"required": true
2861+
},
2862+
{
2863+
"in": "path",
2864+
"name": "mailbox_id",
2865+
"schema": {
2866+
"type": "string",
2867+
"format": "uuid"
2868+
},
2869+
"required": true
2870+
}
2871+
],
2872+
"tags": [
2873+
"channels"
2874+
],
2875+
"security": [
2876+
{
2877+
"cookieAuth": []
2878+
}
2879+
],
2880+
"responses": {
2881+
"200": {
2882+
"description": "New password generated"
2883+
},
2884+
"403": {
2885+
"description": "Permission denied"
2886+
},
2887+
"404": {
2888+
"description": "Channel not found"
2889+
}
2890+
}
2891+
}
2892+
},
28212893
"/api/v1.0/mailboxes/{mailbox_id}/image-proxy/": {
28222894
"get": {
28232895
"operationId": "mailboxes_image_proxy_list",

src/backend/core/api/serializers.py

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import hashlib
55
import json
6+
import secrets
67
import uuid
78

89
from django.conf import settings
@@ -16,6 +17,14 @@
1617
from core import enums, models
1718
from core.mda.rfc5322 import extract_base64_images_from_html
1819

20+
# Base58 alphabet — no ambiguous characters (0, O, I, l)
21+
BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
22+
23+
24+
def generate_base58_password(length=16):
25+
"""Generate a password using base58 alphabet. 16 chars ≈ 94 bits of entropy."""
26+
return "".join(secrets.choice(BASE58_ALPHABET) for _ in range(length))
27+
1928

2029
class CreateOnlyFieldsMixin:
2130
"""Mixin that makes specified fields read-only on update (when instance exists).
@@ -1175,9 +1184,7 @@ def validate(self, attrs):
11751184

11761185
if metadata.get("type") == "personal":
11771186
local_part = attrs.get("local_part", "")
1178-
denylist = getattr(
1179-
settings, "MESSAGES_MAILBOX_LOCALPART_DENYLIST_PERSONAL", []
1180-
)
1187+
denylist = settings.MESSAGES_MAILBOX_LOCALPART_DENYLIST_PERSONAL
11811188
lower_value = local_part.lower()
11821189
if any(lower_value == prefix.lower() for prefix in denylist):
11831190
raise serializers.ValidationError(
@@ -1491,6 +1498,83 @@ class Meta:
14911498
]
14921499
read_only_fields = ["id", "mailbox", "maildomain", "created_at", "updated_at"]
14931500

1501+
# Keys in settings that should be moved to encrypted_settings, per channel type.
1502+
ENCRYPTED_SETTINGS_KEYS = {
1503+
"client-bridge": ["password"],
1504+
}
1505+
1506+
def _move_sensitive_settings(self, validated_data):
1507+
"""Move sensitive keys from settings to encrypted_settings."""
1508+
channel_type = validated_data.get("type") or (
1509+
self.instance.type if self.instance else None
1510+
)
1511+
keys_to_encrypt = self.ENCRYPTED_SETTINGS_KEYS.get(channel_type, [])
1512+
if not keys_to_encrypt:
1513+
return validated_data
1514+
1515+
settings_data = validated_data.get("settings")
1516+
if not settings_data:
1517+
return validated_data
1518+
1519+
extracted = {
1520+
key: settings_data.pop(key)
1521+
for key in keys_to_encrypt
1522+
if key in settings_data
1523+
}
1524+
if extracted:
1525+
existing = (self.instance.encrypted_settings or {}) if self.instance else {}
1526+
validated_data["encrypted_settings"] = {**existing, **extracted}
1527+
1528+
return validated_data
1529+
1530+
def create(self, validated_data):
1531+
validated_data = self._move_sensitive_settings(validated_data)
1532+
if validated_data.get("type") == "client-bridge":
1533+
password = generate_base58_password()
1534+
validated_data.setdefault("encrypted_settings", {})
1535+
validated_data["encrypted_settings"]["password"] = password
1536+
# Remove any user-supplied password from settings
1537+
if "settings" in validated_data and "password" in (
1538+
validated_data["settings"] or {}
1539+
):
1540+
validated_data["settings"].pop("password")
1541+
# Set default role if not provided
1542+
validated_data.setdefault("settings", {})
1543+
validated_data["settings"].setdefault("role", "sender")
1544+
# Validate role
1545+
role = validated_data["settings"]["role"]
1546+
if role not in enums.CLIENT_BRIDGE_ROLES:
1547+
raise serializers.ValidationError(
1548+
{
1549+
"settings": {
1550+
"role": f"Invalid role. Must be one of: {', '.join(enums.CLIENT_BRIDGE_ROLES)}"
1551+
}
1552+
}
1553+
)
1554+
instance = super().create(validated_data)
1555+
# Stash password so the view can return it once
1556+
instance._generated_password = password # noqa: SLF001 # pylint: disable=protected-access
1557+
return instance
1558+
return super().create(validated_data)
1559+
1560+
def update(self, instance, validated_data):
1561+
validated_data = self._move_sensitive_settings(validated_data)
1562+
channel_type = validated_data.get("type") or (
1563+
instance.type if instance else None
1564+
)
1565+
if channel_type == "client-bridge":
1566+
settings_data = validated_data.get("settings") or {}
1567+
if "role" in settings_data:
1568+
if settings_data["role"] not in enums.CLIENT_BRIDGE_ROLES:
1569+
raise serializers.ValidationError(
1570+
{
1571+
"settings": {
1572+
"role": f"Invalid role. Must be one of: {', '.join(enums.CLIENT_BRIDGE_ROLES)}"
1573+
}
1574+
}
1575+
)
1576+
return super().update(instance, validated_data)
1577+
14941578
def validate_settings(self, value):
14951579
"""Validate settings, including tags if present."""
14961580
if not value:
@@ -1550,14 +1634,21 @@ def validate(self, attrs):
15501634
if self.context.get("mailbox"):
15511635
channel_type = attrs.get("type")
15521636
if channel_type:
1553-
allowed_types = settings.FEATURE_MAILBOX_ADMIN_CHANNELS
1637+
allowed_types = list(settings.FEATURE_MAILBOX_ADMIN_CHANNELS)
15541638
if channel_type not in allowed_types:
15551639
raise serializers.ValidationError(
15561640
{
15571641
"type": f"Channel type '{channel_type}' is not authorized. "
15581642
f"Allowed types: {', '.join(allowed_types)}"
15591643
}
15601644
)
1645+
if (
1646+
channel_type == "client-bridge"
1647+
and not settings.FEATURE_CLIENTBRIDGE
1648+
):
1649+
raise serializers.ValidationError(
1650+
{"type": "Client bridge feature is not enabled."}
1651+
)
15611652
return attrs
15621653

15631654
mailbox = attrs.get("mailbox")

0 commit comments

Comments
 (0)