Compare commits

...

108 Commits

Author SHA1 Message Date
b88e51c8ea Initial commit 2026-03-27 09:24:55 +01:00
28447549c4 Initial commit 2026-03-27 09:24:32 +01:00
082495c5d2 Initial commit 2026-03-27 09:22:46 +01:00
76717262b3 Update nx-tldraw/docker-compose.yml 2026-03-27 07:57:10 +00:00
e0d3aa820b Update nx-tldraw/docker-compose.yml 2026-03-27 07:50:51 +00:00
710f5d93c8 nx-webmail: fix hydration race and ensure continued inbox loading
Some checks failed
Publish nx-webmail Image (Gitea) / publish (push) Has been cancelled
2026-02-23 15:14:48 +01:00
4277786459 nx-webmail: add robust paged inbox hydration after login
Some checks failed
Publish nx-webmail Image (Gitea) / publish (push) Has been cancelled
2026-02-23 15:06:52 +01:00
08a8ae182e nx-webmail: fix full mailbox sync path for large inboxes
Some checks failed
Publish nx-webmail Image (Gitea) / publish (push) Has been cancelled
2026-02-23 14:24:51 +01:00
1626984faf nx-webmail: fix login viewport overflow and initial sync behavior
Some checks failed
Publish nx-webmail Image (Gitea) / publish (push) Has been cancelled
2026-02-23 14:15:52 +01:00
ceed81eb15 Icon 2026-02-23 13:46:36 +01:00
60a6a3222b nx-webmail: improve post-login full mailbox sync
Some checks failed
Publish nx-webmail Image (Gitea) / publish (push) Has been cancelled
2026-02-23 13:45:51 +01:00
81c77bce6a nx-webmail: refine login hero corners and typography
Some checks failed
Publish nx-webmail Image (Gitea) / publish (push) Has been cancelled
2026-02-23 13:34:43 +01:00
7f43441367 nx-webmail: redesign login and force fullscreen webmail
Some checks failed
Publish nx-webmail Image (Gitea) / publish (push) Has been cancelled
2026-02-23 13:25:41 +01:00
ecc0cd4b6c nx-webmail: center login window and release 1.0.2
Some checks failed
Publish nx-webmail Image (Gitea) / publish (push) Has been cancelled
2026-02-23 13:13:48 +01:00
b522a9c02f nx-webmail: pin gitea registry image digest
Some checks failed
Publish nx-webmail Image (Gitea) / publish (push) Has been cancelled
2026-02-23 13:05:36 +01:00
e165594808 GIt commit
Some checks failed
Publish nx-webmail Image (Gitea) / publish (push) Has been cancelled
2026-02-23 13:03:32 +01:00
4eca638529 ci: add gitea workflow for image publish and digest pin
Some checks failed
Publish nx-webmail Image (Gitea) / publish (push) Has been cancelled
2026-02-23 12:03:19 +01:00
5a8db0c82c ci: auto-pin nx-webmail image digest in compose
Some checks failed
Publish nx-webmail Image / publish (push) Has been cancelled
2026-02-23 12:01:48 +01:00
382b11cdd8 nx-webmail: switch Umbrel app to prebuilt GHCR image
Some checks failed
Publish nx-webmail Image / publish (push) Has been cancelled
2026-02-23 11:58:29 +01:00
0d0174b715 Webmail changes 2026-02-23 11:53:55 +01:00
c27e2da6ae Weektab Webmail 2026-02-23 11:45:18 +01:00
0eef705ac9 Changed the default tag 2026-02-06 09:39:01 +01:00
c6b22b7824 cpn-tldraw/docker-compose.yml aktualisiert 2026-02-06 08:12:02 +00:00
791e8405c8 cpn-tldraw/umbrel-app.yml aktualisiert 2026-02-06 08:11:49 +00:00
68e8296d6b umbrel-app-store.yml aktualisiert 2026-02-06 08:11:16 +00:00
dd0cf04fc2 Updated Postiz 2026-01-21 13:48:59 +01:00
c1e6615d30 CPN commit 2026-01-21 12:55:51 +01:00
526dcc69a5 Updated docker-compose 2026-01-21 12:41:40 +01:00
1f88db5491 CPN commit 2026-01-21 12:27:30 +01:00
66f2fd948a Added Postiz 2026-01-21 12:24:28 +01:00
810f3a89a6 CPN commit 2026-01-21 11:41:28 +01:00
9fb25dcb0a Commit 2026-01-20 00:16:58 +01:00
00538d227a Updated Tianji 2026-01-20 00:15:03 +01:00
e5e02df6e5 Commit 2026-01-20 00:07:18 +01:00
6f27e83f37 Added Tianji 2026-01-20 00:03:56 +01:00
fbef988653 Default commit 2026-01-20 00:03:12 +01:00
166fb800ec Updated Tianji 2026-01-19 23:38:35 +01:00
ba847d7a44 Updated Tianji 2026-01-19 23:37:25 +01:00
510bda1a23 Updated Tianji 2026-01-19 23:35:21 +01:00
cb683d668e Updated Tianji 2026-01-19 23:18:45 +01:00
f3ef17102a Updated Tianji 2026-01-19 23:16:05 +01:00
f488040ee3 Updated Tianji 2026-01-19 22:49:01 +01:00
eacaf22ae1 Updated Tianji 2026-01-19 22:41:49 +01:00
6583e27fce Updated Tianji 2026-01-19 22:14:14 +01:00
bff7271552 Updated Tianji 2026-01-19 21:55:53 +01:00
ccef4e3854 Updated docker-compose (Tianji) 2026-01-19 21:39:31 +01:00
e461e56efb Updated Tianji 2026-01-19 21:38:11 +01:00
f83156616b Updated Tianji 2026-01-19 21:34:29 +01:00
bd3f524d59 Updated Tianji 2026-01-19 21:28:28 +01:00
6802003f37 Updated Tianji 2026-01-19 20:20:19 +01:00
52ebdc1065 Updated Tianji 2026-01-19 20:18:58 +01:00
d05125bafd Updated Tianji 2026-01-19 20:04:45 +01:00
8a563e2018 Added Tianji 2026-01-19 19:55:06 +01:00
3603d9e808 TeamSpeak Server 2025-11-29 13:25:23 +01:00
b94aebfbd6 n8n v1.222.4 2025-11-29 13:15:25 +01:00
0296a7f0a8 Update app 2025-11-29 12:38:58 +01:00
a07e25b509 Update n8n 2025-11-29 12:37:26 +01:00
99f42f8eea Update n8n 2025-11-29 12:29:12 +01:00
20ead47da9 Update app 2025-11-29 12:20:45 +01:00
403b3f260c Update n8n 2025-11-29 12:17:05 +01:00
025638347b Update n8n 2025-11-29 12:04:55 +01:00
97892e304b Update n8n 2025-11-29 11:54:21 +01:00
d5786682fe Update n8n 2025-11-29 11:40:17 +01:00
d7a0652ccb Update n8n 2025-11-29 11:39:20 +01:00
be91215345 Update n8n 2025-11-29 11:37:50 +01:00
e665c0245a Update n8n 2025-11-29 11:30:58 +01:00
9a47328cc0 Update n8n 2025-11-29 11:28:37 +01:00
e20a5aca69 Update n8n 2025-11-29 11:26:18 +01:00
da2b0c24d5 Update n8n 2025-11-29 11:20:36 +01:00
0ba4765c4e tldraw commit 2025-11-29 01:45:07 +01:00
a4209a0f2a tldraw commit 2025-11-29 01:34:38 +01:00
bc9b8f19d0 tldraw commit 2025-11-29 01:29:21 +01:00
13a7c9069f Update 2025-11-29 00:53:54 +01:00
f8ea091cce Temporarily disabled 2025-11-29 00:51:53 +01:00
4a4cb6a0a8 Update lancache 2025-11-29 00:35:41 +01:00
185e079f1d Update lancache 2025-11-29 00:33:47 +01:00
09cc8f1c6c Update lancache 2025-11-29 00:21:56 +01:00
2d4a72821e Update lancache 2025-11-29 00:20:53 +01:00
4ff396c0fb Update lancache 2025-11-29 00:13:14 +01:00
b34b6dda4b Update lanCACHE 2025-11-28 23:52:23 +01:00
74825fa106 Update lancache 2025-11-28 23:47:29 +01:00
d34b524f59 Update lancache 2025-11-28 23:16:45 +01:00
2baf1421e2 Update lancache 2025-11-28 23:12:25 +01:00
0d40e6c314 Update lancache 2025-11-28 23:02:51 +01:00
a2954e3b8f Update lancache 2025-11-28 23:00:54 +01:00
92fae1ca2b Update lancache 2025-11-28 22:55:10 +01:00
00b054c31c Update lancache 2025-11-28 22:45:18 +01:00
c8c04be093 Update lancache 2025-11-28 21:06:14 +01:00
20d1e16580 Update lancache 2025-11-28 21:01:04 +01:00
f7bd55f1dd Update lancache 2025-11-28 20:56:41 +01:00
e3d7b7d054 Update lancache 2025-11-28 20:54:09 +01:00
67f0b2eb38 Update lancache 2025-11-28 20:49:23 +01:00
66d713c559 Update lancache 2025-11-28 20:43:33 +01:00
c130cab73c Update lancache 2025-11-28 20:32:01 +01:00
fba7697ffe Update README.md 2025-11-28 20:02:48 +01:00
c982f20939 CPN commit 2025-11-28 20:00:11 +01:00
b449ad930a App Store 2025-11-28 18:56:13 +01:00
e2c350f46c lancache 2025-11-28 16:42:16 +01:00
a149772899 CPN commit 2025-11-28 16:37:15 +01:00
3e830bc773 CPN commit 2025-11-28 10:29:10 +01:00
eadd797488 n8n 2025-11-28 10:08:51 +01:00
00c5c434e6 n8n 2025-11-28 10:05:54 +01:00
1eb090555a n8n 2025-11-28 10:04:00 +01:00
004fa660e8 CPN commit 2025-11-28 09:58:15 +01:00
1565933115 CPN commit 2025-11-28 09:55:50 +01:00
2058da6700 CPN commit 2025-11-25 22:43:39 +01:00
0d0649d28f CPN commit 2025-11-25 22:28:58 +01:00
60fd24ff19 CPN commit 2025-11-25 22:28:18 +01:00
28 changed files with 4099 additions and 147 deletions

View File

@@ -0,0 +1,82 @@
name: Publish nx-webmail Image (Gitea)
on:
push:
branches:
- main
paths:
- "nx-webmail/**"
- ".gitea/workflows/publish-nx-webmail-image.yml"
workflow_dispatch:
jobs:
publish:
if: ${{ !contains(gitea.actor, '[bot]') }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.weektab.org
username: ${{ secrets.GITEA_USERNAME }}
password: ${{ secrets.GITEA_TOKEN }}
- name: Read app version
id: meta
shell: bash
run: |
version=$(grep '^version:' nx-webmail/umbrel-app.yml | awk -F'"' '{print $2}')
if [ -z "$version" ]; then
echo "Could not read nx-webmail version from umbrel-app.yml" >&2
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "image=git.weektab.org/nexus/nx-webmail" >> "$GITHUB_OUTPUT"
- name: Build and push image
id: build
uses: docker/build-push-action@v6
with:
context: ./nx-webmail
file: ./nx-webmail/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}
${{ steps.meta.outputs.image }}:latest
- name: Pin digest in docker-compose
shell: bash
run: |
digest="${{ steps.build.outputs.digest }}"
if [ -z "$digest" ]; then
echo "No digest returned by build step" >&2
exit 1
fi
pinned=" image: ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}@${digest}"
sed -i -E "s|^ image: .*|$pinned|" nx-webmail/docker-compose.yml
- name: Commit digest pin
shell: bash
run: |
if git diff --quiet -- nx-webmail/docker-compose.yml; then
echo "No docker-compose digest changes to commit."
exit 0
fi
git config user.name "gitea-actions[bot]"
git config user.email "gitea-actions@local"
git remote set-url origin "https://${{ secrets.GITEA_USERNAME }}:${{ secrets.GITEA_TOKEN }}@git.weektab.org/${{ gitea.repository }}.git"
git add nx-webmail/docker-compose.yml
git commit -m "nx-webmail: pin image digest [skip ci]"
git push origin HEAD:main

View File

@@ -0,0 +1,96 @@
name: Publish nx-webmail Image
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "nx-webmail/**"
- ".github/workflows/publish-nx-webmail-image.yml"
permissions:
contents: write
packages: write
jobs:
publish:
if: github.actor != 'github-actions[bot]'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Read app version
id: meta
shell: bash
run: |
version=$(grep '^version:' nx-webmail/umbrel-app.yml | awk -F'"' '{print $2}')
if [ -z "$version" ]; then
echo "Could not read nx-webmail version from umbrel-app.yml" >&2
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "image=ghcr.io/weektab/nx-webmail" >> "$GITHUB_OUTPUT"
- name: Build and push image
id: build
uses: docker/build-push-action@v6
with:
context: ./nx-webmail
file: ./nx-webmail/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}
${{ steps.meta.outputs.image }}:latest
- name: Pin digest in docker-compose
shell: bash
run: |
digest="${{ steps.build.outputs.digest }}"
if [ -z "$digest" ]; then
echo "No digest returned by build step" >&2
exit 1
fi
pinned=" image: ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}@${digest}"
sed -i -E "s|^ image: .*|$pinned|" nx-webmail/docker-compose.yml
- name: Commit digest pin
shell: bash
run: |
if git diff --quiet -- nx-webmail/docker-compose.yml; then
echo "No docker-compose digest changes to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add nx-webmail/docker-compose.yml
git commit -m "nx-webmail: pin image digest [skip ci]"
git push
- name: Summary
shell: bash
run: |
{
echo "### nx-webmail image published"
echo ""
echo "- Image: \`${{ steps.meta.outputs.image }}\`"
echo "- Version tag: \`${{ steps.meta.outputs.version }}\`"
echo "- Digest: \`${{ steps.build.outputs.digest }}\`"
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -1,2 +1,8 @@
# umbrel-apps
# Umbrel Community App Store
This repository includes apps from public repositories that I have prepared and packaged for use on Umbrel. Some are shared by the community, and all have been adapted for easy installation on Umbrel.
---
![Umbrel Files](https://git.weektab.org/nexus/umbrel-apps/raw/branch/main/gallery/umbrel-files.jpeg)
![Community App Store](https://git.weektab.org/nexus/umbrel-apps/raw/branch/main/gallery/umbrel-app-store.jpeg)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,59 +0,0 @@
# Configuration for lancache setup
# Name of the cpn-lancache application
version: "3.7"
# Service definitions for the cpn-lancache application
services:
app_proxy:
environment:
APP_HOST: cpn-lancache_app_1
APP_PORT: "80"
# Service name: app
app:
image: lancachenet/monolithic:latest # Docker image for the caching service
environment:
# Toggle for load balancer usage or separate IPs for each service
- USE_GENERIC_CACHE=true
# IPs for lancache monolithic instance; affects DNS resolution
- LANCACHE_IP=10.0.39.1
# Host IP for the DNS server binding
- DNS_BIND_IP=10.0.39.1
# Upstream DNS for forwarded lookups
- UPSTREAM_DNS=8.8.8.8
# Local storage path for cache data
- CACHE_ROOT=./lancache
# Customizable disk cache size
- CACHE_DISK_SIZE=2000g
# Memory allocation for nginx cache indexing
- CACHE_INDEX_SIZE=500m
# Maximum age for cached content
- CACHE_MAX_AGE=3650d
# Container timezone setting for log timestamps
- TZ=UTC
restart: unless-stopped
volumes:
# Mount points for cache and logs
- ${APP_DATA_DIR}/cache:/data/cache
- ${APP_DATA_DIR}/logs:/data/logs
# DNS service configuration
dns:
image: lancachenet/lancache-dns:latest # Docker image for DNS service
environment:
# Toggle for load balancer usage or separate IPs for each service
- USE_GENERIC_CACHE=true
# IPs for lancache monolithic instance; affects DNS resolution
- LANCACHE_IP=10.0.39.1
# Host IP for the DNS server binding
- DNS_BIND_IP=10.0.39.1
# Upstream DNS for forwarded lookups
- UPSTREAM_DNS=8.8.8.8
# Local storage path for cache data
- CACHE_ROOT=./lancache
# Customizable disk cache size
- CACHE_DISK_SIZE=2000g
# Memory allocation for nginx cache indexing
- CACHE_INDEX_SIZE=500m
# Maximum age for cached content
- CACHE_MAX_AGE=3650d
# Container timezone setting for log timestamps
- TZ=UTC
restart: unless-stopped

View File

@@ -1,26 +0,0 @@
manifestVersion: 1
id: cpn-lancache
category: networking
name: LAN Cache
version: "latest"
tagline: LAN Party game caching made easy
description: >-
Make the most of your network. Get more play for your gamers. Download your games once and serve them out to many people at your LAN.
releaseNotes: >-
This version includes various improvements and bug fixes.
developer: lancachenet
website: https://hub.docker.com/r/lancachenet/monolithic
dependencies: []
repo: https://git.weektab.org/companas/umbrel-apps/src/branch/main/cpn-lancache
support: https://git.weektab.org/companas/umbrel-apps/issues
port: 10085
gallery:
- image-1.jpg
- image-2.jpg
- image-3.jpg
path: ""
defaultUsername: ""
defaultPassword: ""
icon: https://cdn.jsdelivr.net/gh/selfhst/icons/png/lancache-net.png
submitter: Weektab
submission: https://git.weektab.org/companas/umbrel-apps

View File

@@ -1,33 +0,0 @@
version: "3.8"
services:
teamspeak:
image: teamspeak
restart: always
ports:
- 9987:9987/udp
- 10011:10011
- 30033:30033
environment:
TS3SERVER_DB_PLUGIN: ts3db_mariadb
TS3SERVER_DB_SQLCREATEPATH: create_mariadb
TS3SERVER_DB_HOST: db
TS3SERVER_DB_USER: root
TS3SERVER_DB_PASSWORD: EDFGD654hgrggh45ERG
TS3SERVER_DB_NAME: teamspeak
TS3SERVER_DB_WAITUNTILREADY: 30
TS3SERVER_LICENSE: accept
db:
image: mariadb
restart: always
environment:
MYSQL_ROOT_PASSWORD: EDFGD654hgrggh45ERG
MYSQL_DATABASE: teamspeak
ts3-manger:
image: "joni1802/ts3-manager"
ports:
- 4990:8080
environment:
- WHITELIST=
- JWT_SECRET=###

View File

@@ -1,26 +0,0 @@
manifestVersion: 1
id: cpn-teamspeak-3
name: TeamSpeak3 Server
tagline:
icon: https://theme.zdassets.com/theme_assets/9496188/a876ae79f06eee3404dcf59a7ad4dd32e8e53d8e.png
category: Communication
version: "1.0.3"
port: 4990
description: >-
Install TeamSpeak3 with Webinterface on Umbrel
developer: TeamSpeak
website: https://www.teamspeak.com/
submitter: Weektab
submission: https://git.weektab.org/companas/umbrel-apps
repo: https://git.weektab.org/companas/umbrel-apps/src/branch/main/cpn-teamspeak-3
support: https://git.weektab.org/companas/umbrel-apps/issues
gallery:
- https://www.myqnap.org/wp-content/uploads/FVxrCuqWYAc6Up7.jpg
- https://camo.githubusercontent.com/3d538a7c35182500e600dff58e534bbeb16520795bb088b4c217abb3519f14e8/68747470733a2f2f692e696d6775722e636f6d2f75503358674b692e706e67
releaseNotes: >
dependencies: []
path: ""
defaultUsername: "See you in the Logs"
defaultPassword: "See you in the Logs"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
gallery/umbrel-files.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
gallery/webmail/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

9
nx-webmail/.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
npm-debug.log
dist
.git
.gitignore
Dockerfile*
.dockerignore
.env
.env.*

2
nx-webmail/.env.example Normal file
View File

@@ -0,0 +1,2 @@
PORT=3001
NODE_ENV=production

23
nx-webmail/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
# syntax=docker/dockerfile:1
FROM node:20-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package*.json ./
RUN npm install
FROM deps AS build
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3001
COPY package*.json ./
COPY --from=deps /app/node_modules ./node_modules
RUN npm prune --omit=dev
COPY --from=build /app/dist ./dist
COPY ./server.js ./server.js
EXPOSE 3001
CMD ["node", "server.js"]

64
nx-webmail/README.md Normal file
View File

@@ -0,0 +1,64 @@
# Standalone Webmail (Docker-ready)
## Local setup
1. Install dependencies:
npm install
2. Run dev mode (Vite + API):
npm run dev
3. Open:
http://localhost:5173
## Docker (standalone)
1. Copy env file:
cp .env.example .env
2. Build + run:
docker compose up --build -d
3. Open:
http://localhost:3001
## Publish image for Umbrel
Umbrel installation is most reliable when your app uses a prebuilt image from a registry.
1. Recommended: use the GitHub Action `.github/workflows/publish-nx-webmail-image.yml`.
- For Gitea use `.gitea/workflows/publish-nx-webmail-image.yml`.
- Required Gitea secrets: `GITEA_USERNAME`, `GITEA_TOKEN`.
2. The workflow reads `version` from `nx-webmail/umbrel-app.yml` and publishes:
- `git.weektab.org/nexus/nx-webmail:<version>`
- `git.weektab.org/nexus/nx-webmail:latest`
3. The workflow then pins `nx-webmail/docker-compose.yml` to `tag@sha256:digest` automatically.
4. Manual fallback:
docker buildx build --platform linux/amd64,linux/arm64 -t git.weektab.org/nexus/nx-webmail:1.0.9 --push .
## Umbrel app packaging
This repository is prepared for Umbrel app-store usage.
1. Keep this app in a folder named exactly like the app ID: `nx-webmail`.
2. Keep these files in that folder:
- `docker-compose.yml`
- `umbrel-app.yml`
- `Dockerfile`
- `exports.sh`
3. Ensure your store root contains `umbrel-app-store.yml`.
4. Push the app-store repo to a git host reachable from Umbrel.
## Install from Umbrel UI
1. Open App Store in Umbrel.
2. Use the menu to add a Community App Store.
3. Paste your repository URL.
4. Open your store and install `Webmail`.
Notes:
- Umbrel uses the `app_proxy` service in `docker-compose.yml`.
- Internal app port is `3001`.
- `server` uses a prebuilt registry image (`git.weektab.org/nexus/nx-webmail:1.0.9`).
- If your store prefix changes, update `id` in `umbrel-app.yml` and `APP_HOST` in `docker-compose.yml`.
## Notes
- Frontend calls API on `/api/webmail/*`.
- IMAP/SMTP credentials are provided by user login in UI.

View File

@@ -0,0 +1,16 @@
version: "3.7"
services:
app_proxy:
environment:
APP_HOST: nx-webmail_server_1
APP_PORT: 3001
server:
image: git.weektab.org/nexus/nx-webmail:1.0.9@sha256:bd8d9c38edbc43924a69509d7d1e19e343ead17e7d87f7b52dda540f09e9a3e9
init: true
restart: on-failure
stop_grace_period: 1m
environment:
NODE_ENV: production
PORT: 3001

2
nx-webmail/exports.sh Normal file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env bash
# No dynamic env exports are required for this app.

12
nx-webmail/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Webmail</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

26
nx-webmail/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "weektab-webmail-standalone",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently \"vite\" \"node server.js\"",
"build": "vite build",
"start": "node server.js"
},
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.21.2",
"imap-simple": "^5.1.0",
"mailparser": "^3.7.2",
"nodemailer": "^6.10.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"remixicon": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.1.2",
"vite": "^5.4.11"
}
}

346
nx-webmail/server.js Normal file
View File

@@ -0,0 +1,346 @@
import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
import crypto from 'crypto';
import dotenv from 'dotenv';
import imaps from 'imap-simple';
import { simpleParser } from 'mailparser';
import nodemailer from 'nodemailer';
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
app.use(express.json({ limit: '5mb' }));
function imapConfig({ user, password, host, port, tls }) {
return {
imap: {
user,
password,
host,
port: Number(port || 993),
tls: tls !== false,
authTimeout: 10000,
connTimeout: 15000,
tlsOptions: { rejectUnauthorized: false }
}
};
}
function extractSenderEmail(value) {
const raw = String(value || '').trim();
const match = raw.match(/<([^>]+)>/);
return (match?.[1] || raw).trim().toLowerCase();
}
function gravatarUrl(senderValue) {
const email = extractSenderEmail(senderValue);
if (!email || !email.includes('@')) return '';
const hash = crypto.createHash('md5').update(email).digest('hex');
return `https://www.gravatar.com/avatar/${hash}?s=64&d=404`;
}
function flattenBoxes(tree, prefix = '') {
const names = [];
for (const key of Object.keys(tree || {})) {
const box = tree[key];
const delimiter = box?.delimiter || '/';
const full = prefix ? `${prefix}${delimiter}${key}` : key;
if (!box?.attribs || !box.attribs.includes('\\Noselect')) names.push(full);
if (box?.children) names.push(...flattenBoxes(box.children, full));
}
return names;
}
function parseHeaderRow(res) {
const header = res.parts.find((p) => p.which === 'HEADER.FIELDS (FROM TO SUBJECT DATE)')?.body || {};
const textPart = res.parts.find((p) => p.which === 'TEXT');
let snippet = '';
if (textPart?.body) {
snippet = String(textPart.body)
.replace(/(<([^>]+)>)/gi, '')
.replace(/\s+/g, ' ')
.trim()
.substring(0, 120);
if (snippet) snippet += '...';
}
const sender = header.from ? header.from[0] : 'Unknown';
return {
id: res.attributes.uid,
subject: header.subject ? header.subject[0] : '(No Subject)',
sender,
avatar: gravatarUrl(sender),
time: header.date ? new Date(header.date[0]).toLocaleString() : '',
unread: !res.attributes.flags.includes('\\Seen'),
snippet
};
}
app.post('/api/webmail/connect', async (req, res) => {
const { user, password, host } = req.body;
if (!user || !password || !host) return res.status(400).json({ error: 'Missing credentials' });
let connection;
try {
connection = await imaps.connect(imapConfig(req.body));
const boxes = await connection.getBoxes();
const folderNames = flattenBoxes(boxes);
const folders = [];
for (const name of folderNames) {
try {
const box = await connection.openBox(name, true);
folders.push({ name, total: box.messages.total || 0, unseen: box.messages.unseen || 0 });
} catch {
folders.push({ name, total: 0, unseen: 0 });
}
}
res.json({ success: true, folders });
} catch (err) {
res.status(500).json({ error: err.message || 'Failed to connect to IMAP server' });
} finally {
if (connection) connection.end();
}
});
app.post('/api/webmail/inbox', async (req, res) => {
const {
user, password, host, folder = 'INBOX',
offset = 0, limit = 50, fetchAll = false,
direction = 'newest', sync = false, sinceUid = 0, knownUids = []
} = req.body;
if (!user || !password || !host) return res.status(400).json({ error: 'Missing credentials' });
let connection;
try {
connection = await imaps.connect(imapConfig(req.body));
await connection.openBox(folder);
const safeOffset = Math.max(0, Number(offset) || 0);
const safeLimit = Math.max(1, Math.min(200, Number(limit) || 50));
const loadAll = fetchAll === true || String(fetchAll).toLowerCase() === 'true';
const oldest = String(direction).toLowerCase() === 'oldest';
const syncMode = sync === true || String(sync).toLowerCase() === 'true';
const fetchOptions = {
bodies: ['HEADER.FIELDS (FROM TO SUBJECT DATE)', 'TEXT'],
struct: true,
markSeen: false
};
const allUids = await new Promise((resolve, reject) => {
connection.imap.search(['ALL'], (err, results) => {
if (err) return reject(err);
resolve(Array.isArray(results) ? results : []);
});
});
const sortedUids = [...allUids].sort((a, b) => a - b);
const total = sortedUids.length;
if (syncMode) {
const since = Math.max(0, Number(sinceUid) || 0);
const known = new Set(Array.isArray(knownUids) ? knownUids.map((u) => Number(u)).filter((u) => Number.isFinite(u) && u > 0) : []);
const current = new Set(sortedUids);
const deletedUids = [...known].filter((uid) => !current.has(uid));
const newUidList = sortedUids.filter((uid) => uid > since);
let newResults = [];
if (newUidList.length > 0) {
newResults = await connection.search([['UID', newUidList.join(',')]], fetchOptions);
}
const newEmails = newResults.map(parseHeaderRow).sort((a, b) => Number(a.id || 0) - Number(b.id || 0));
return res.json({ sync: true, total, newEmails, deletedUids });
}
let results = [];
let targetUids = sortedUids;
if (loadAll) {
// For full mailbox sync, use ALL to avoid oversized UID query strings.
results = await connection.search(['ALL'], fetchOptions);
} else {
if (oldest) {
const start = safeOffset;
const end = Math.min(total, start + safeLimit);
targetUids = sortedUids.slice(start, end);
} else {
const end = Math.max(0, total - safeOffset);
const start = Math.max(0, end - safeLimit);
targetUids = sortedUids.slice(start, end);
}
if (targetUids.length === 0) {
return res.json({ emails: [], total, hasMore: false, nextOffset: safeOffset });
}
results = await connection.search([['UID', targetUids.join(',')]], fetchOptions);
}
const emails = results.map(parseHeaderRow).sort((a, b) => Number(a.id || 0) - Number(b.id || 0));
const nextOffset = loadAll ? total : Math.min(total, safeOffset + targetUids.length);
const hasMore = loadAll ? false : nextOffset < total;
res.json({ emails, total, hasMore, nextOffset });
} catch (err) {
res.status(500).json({ error: err.message || 'Failed to fetch inbox' });
} finally {
if (connection) connection.end();
}
});
app.post('/api/webmail/message', async (req, res) => {
const { user, password, host, folder = 'INBOX', uid } = req.body;
if (!user || !password || !host || !uid) return res.status(400).json({ error: 'Missing credentials or UID' });
let connection;
try {
connection = await imaps.connect(imapConfig(req.body));
await connection.openBox(folder);
const results = await connection.search([['UID', uid]], { bodies: [''], markSeen: true });
if (!results.length) return res.status(404).json({ error: 'Message not found' });
const rawEmail = results[0].parts.find((p) => p.which === '').body;
const parsed = await simpleParser(rawEmail);
const hasHtmlBody = typeof parsed.html === 'string' && parsed.html.trim().length > 0;
let html = parsed.html || parsed.textAsHtml || parsed.text || '';
if (typeof html !== 'string') html = String(html || '');
const inlineAttachments = (parsed.attachments || []).filter((a) => a?.content && (a?.cid || a?.contentId));
if (html && inlineAttachments.length > 0) {
for (const attachment of inlineAttachments) {
const rawCid = String(attachment.cid || attachment.contentId || '').trim();
const cid = rawCid.replace(/^<|>$/g, '');
if (!cid) continue;
const mime = String(attachment.contentType || 'application/octet-stream');
const base64 = Buffer.from(attachment.content).toString('base64');
const safeCid = cid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
html = html.replace(new RegExp(`cid:${safeCid}`, 'gi'), `data:${mime};base64,${base64}`);
}
}
res.json({
id: uid,
subject: parsed.subject,
sender: parsed.from ? parsed.from.text : '',
to: parsed.to ? parsed.to.text : '',
date: parsed.date,
html,
isPlainText: !hasHtmlBody,
attachments: (parsed.attachments || []).map((a) => ({ filename: a.filename, size: a.size, contentType: a.contentType }))
});
} catch (err) {
res.status(500).json({ error: err.message || 'Failed to fetch message' });
} finally {
if (connection) connection.end();
}
});
app.post('/api/webmail/send', async (req, res) => {
const { user, password, host, port, to, subject, body } = req.body;
if (!user || !password || !host || !to) return res.status(400).json({ error: 'Missing arguments' });
try {
const transporter = nodemailer.createTransport({
host,
port: Number(port || 465),
secure: Number(port || 465) === 465,
auth: { user, pass: password },
tls: { rejectUnauthorized: false }
});
await transporter.sendMail({ from: user, to, subject: subject || 'No Subject', html: body });
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message || 'Failed to send message' });
}
});
app.post('/api/webmail/delete', async (req, res) => {
const { user, password, host, folder = 'INBOX', uid } = req.body;
if (!user || !password || !host || !uid) return res.status(400).json({ error: 'Missing credentials or UID' });
let connection;
try {
connection = await imaps.connect(imapConfig(req.body));
await connection.openBox(folder);
await connection.addFlags(uid, '\\Deleted');
await new Promise((resolve, reject) => connection.imap.expunge((err) => (err ? reject(err) : resolve())));
res.json({ success: true, uid: Number(uid) });
} catch (err) {
res.status(500).json({ error: err.message || 'Failed to delete message' });
} finally {
if (connection) connection.end();
}
});
app.post('/api/webmail/delete-bulk', async (req, res) => {
const { user, password, host, folder = 'INBOX', uids } = req.body;
const uidList = Array.isArray(uids) ? uids.map((u) => Number(u)).filter((u) => Number.isFinite(u) && u > 0) : [];
if (!user || !password || !host || uidList.length === 0) return res.status(400).json({ error: 'Missing credentials or UIDs' });
let connection;
try {
connection = await imaps.connect(imapConfig(req.body));
await connection.openBox(folder);
await connection.addFlags(uidList, '\\Deleted');
await new Promise((resolve, reject) => connection.imap.expunge((err) => (err ? reject(err) : resolve())));
res.json({ success: true, deleted: uidList.length, uids: uidList });
} catch (err) {
res.status(500).json({ error: err.message || 'Failed to delete selected messages' });
} finally {
if (connection) connection.end();
}
});
app.post('/api/webmail/empty-trash', async (req, res) => {
const { user, password, host, folder = 'INBOX.Trash' } = req.body;
if (!user || !password || !host) return res.status(400).json({ error: 'Missing credentials' });
let connection;
try {
connection = await imaps.connect(imapConfig(req.body));
await connection.openBox(folder);
const uids = await new Promise((resolve, reject) => {
connection.imap.search(['ALL'], (err, results) => {
if (err) return reject(err);
resolve(Array.isArray(results) ? results : []);
});
});
if (!uids.length) return res.json({ success: true, deleted: 0 });
await connection.addFlags(uids, '\\Deleted');
await new Promise((resolve, reject) => connection.imap.expunge((err) => (err ? reject(err) : resolve())));
res.json({ success: true, deleted: uids.length });
} catch (err) {
res.status(500).json({ error: err.message || 'Failed to empty trash' });
} finally {
if (connection) connection.end();
}
});
app.post('/api/webmail/quota', async (_req, res) => {
// Safe default for standalone starter. Can be enhanced for provider-specific quota later.
res.json({ supported: false });
});
app.post('/api/webmail/folder-sizes', async (_req, res) => {
// Safe default for standalone starter. Can be enhanced when needed.
res.json({ success: true, sizes: {} });
});
if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, 'dist')));
app.get('*', (_req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
}
const PORT = Number(process.env.PORT || 3001);
app.listen(PORT, () => {
console.log(`Webmail server running on http://localhost:${PORT}`);
});

File diff suppressed because it is too large Load Diff

7
nx-webmail/src/main.jsx Normal file
View File

@@ -0,0 +1,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import Webmail from './components/Webmail.jsx';
import './styles/webmail.css';
import 'remixicon/fonts/remixicon.css';
createRoot(document.getElementById('root')).render(<Webmail />);

File diff suppressed because it is too large Load Diff

24
nx-webmail/umbrel-app.yml Normal file
View File

@@ -0,0 +1,24 @@
manifestVersion: 1
id: nx-webmail
name: Webmail
tagline: Self-hosted IMAP/SMTP webmail client
icon: https://git.weektab.org/nexus/umbrel-apps/raw/branch/main/gallery/webmail/icon.png
category: utilities
version: "1.0.9"
port: 3001
description: >-
Webmail is a lightweight, self-hosted webmail app for connecting to external
IMAP and SMTP accounts directly from your Umbrel.
developer: Weektab
website: https://git.weektab.org
submitter: Weektab
submission: https://git.weektab.org/nexus/umbrel-apps
repo: https://git.weektab.org/nexus/webmail
support: https://git.weektab.org/nexus/webmail/issues
gallery: []
releaseNotes: >-
Fixed hydration race condition so full inbox loading continues reliably after login.
dependencies: []
path: ""
defaultUsername: ""
defaultPassword: ""

15
nx-webmail/vite.config.js Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:3001'
}
},
build: {
outDir: 'dist'
}
});

View File

@@ -1,2 +1,2 @@
id: "cpn" # Choose the ID for your app store. This should contain only alphabets ("a to z") and dashes ("-").
name: "Companas" # Choose the name of your app store. It will show up in the UI as "<name> App Store".
id: "nx" # Choose the ID for your app store. This should contain only alphabets ("a to z") and dashes ("-").
name: "Nexus" # Choose the name of your app store. It will show up in the UI as "<name> App Store".