Compare commits
108 Commits
ce256415d5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b88e51c8ea | |||
| 28447549c4 | |||
| 082495c5d2 | |||
| 76717262b3 | |||
| e0d3aa820b | |||
| 710f5d93c8 | |||
| 4277786459 | |||
| 08a8ae182e | |||
| 1626984faf | |||
| ceed81eb15 | |||
| 60a6a3222b | |||
| 81c77bce6a | |||
| 7f43441367 | |||
| ecc0cd4b6c | |||
| b522a9c02f | |||
| e165594808 | |||
| 4eca638529 | |||
| 5a8db0c82c | |||
| 382b11cdd8 | |||
| 0d0174b715 | |||
| c27e2da6ae | |||
| 0eef705ac9 | |||
| c6b22b7824 | |||
| 791e8405c8 | |||
| 68e8296d6b | |||
| dd0cf04fc2 | |||
| c1e6615d30 | |||
| 526dcc69a5 | |||
| 1f88db5491 | |||
| 66f2fd948a | |||
| 810f3a89a6 | |||
| 9fb25dcb0a | |||
| 00538d227a | |||
| e5e02df6e5 | |||
| 6f27e83f37 | |||
| fbef988653 | |||
| 166fb800ec | |||
| ba847d7a44 | |||
| 510bda1a23 | |||
| cb683d668e | |||
| f3ef17102a | |||
| f488040ee3 | |||
| eacaf22ae1 | |||
| 6583e27fce | |||
| bff7271552 | |||
| ccef4e3854 | |||
| e461e56efb | |||
| f83156616b | |||
| bd3f524d59 | |||
| 6802003f37 | |||
| 52ebdc1065 | |||
| d05125bafd | |||
| 8a563e2018 | |||
| 3603d9e808 | |||
| b94aebfbd6 | |||
| 0296a7f0a8 | |||
| a07e25b509 | |||
| 99f42f8eea | |||
| 20ead47da9 | |||
| 403b3f260c | |||
| 025638347b | |||
| 97892e304b | |||
| d5786682fe | |||
| d7a0652ccb | |||
| be91215345 | |||
| e665c0245a | |||
| 9a47328cc0 | |||
| e20a5aca69 | |||
| da2b0c24d5 | |||
| 0ba4765c4e | |||
| a4209a0f2a | |||
| bc9b8f19d0 | |||
| 13a7c9069f | |||
| f8ea091cce | |||
| 4a4cb6a0a8 | |||
| 185e079f1d | |||
| 09cc8f1c6c | |||
| 2d4a72821e | |||
| 4ff396c0fb | |||
| b34b6dda4b | |||
| 74825fa106 | |||
| d34b524f59 | |||
| 2baf1421e2 | |||
| 0d40e6c314 | |||
| a2954e3b8f | |||
| 92fae1ca2b | |||
| 00b054c31c | |||
| c8c04be093 | |||
| 20d1e16580 | |||
| f7bd55f1dd | |||
| e3d7b7d054 | |||
| 67f0b2eb38 | |||
| 66d713c559 | |||
| c130cab73c | |||
| fba7697ffe | |||
| c982f20939 | |||
| b449ad930a | |||
| e2c350f46c | |||
| a149772899 | |||
| 3e830bc773 | |||
| eadd797488 | |||
| 00c5c434e6 | |||
| 1eb090555a | |||
| 004fa660e8 | |||
| 1565933115 | |||
| 2058da6700 | |||
| 0d0649d28f | |||
| 60fd24ff19 |
82
.gitea/workflows/publish-nx-webmail-image.yml
Normal file
82
.gitea/workflows/publish-nx-webmail-image.yml
Normal 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
|
||||
96
.github/workflows/publish-nx-webmail-image.yml
vendored
Normal file
96
.github/workflows/publish-nx-webmail-image.yml
vendored
Normal 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"
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||

|
||||
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 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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=###
|
||||
@@ -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"
|
||||
BIN
gallery/umbrel-app-store.jpeg
Normal file
BIN
gallery/umbrel-app-store.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
gallery/umbrel-files.jpeg
Normal file
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
BIN
gallery/webmail/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
9
nx-webmail/.dockerignore
Normal file
9
nx-webmail/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
Dockerfile*
|
||||
.dockerignore
|
||||
.env
|
||||
.env.*
|
||||
2
nx-webmail/.env.example
Normal file
2
nx-webmail/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
PORT=3001
|
||||
NODE_ENV=production
|
||||
23
nx-webmail/Dockerfile
Normal file
23
nx-webmail/Dockerfile
Normal 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
64
nx-webmail/README.md
Normal 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.
|
||||
16
nx-webmail/docker-compose.yml
Normal file
16
nx-webmail/docker-compose.yml
Normal 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
2
nx-webmail/exports.sh
Normal 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
12
nx-webmail/index.html
Normal 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
26
nx-webmail/package.json
Normal 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
346
nx-webmail/server.js
Normal 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}`);
|
||||
});
|
||||
1865
nx-webmail/src/components/Webmail.jsx
Normal file
1865
nx-webmail/src/components/Webmail.jsx
Normal file
File diff suppressed because it is too large
Load Diff
7
nx-webmail/src/main.jsx
Normal file
7
nx-webmail/src/main.jsx
Normal 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 />);
|
||||
1501
nx-webmail/src/styles/webmail.css
Normal file
1501
nx-webmail/src/styles/webmail.css
Normal file
File diff suppressed because it is too large
Load Diff
24
nx-webmail/umbrel-app.yml
Normal file
24
nx-webmail/umbrel-app.yml
Normal 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
15
nx-webmail/vite.config.js
Normal 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'
|
||||
}
|
||||
});
|
||||
@@ -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".
|
||||
Reference in New Issue
Block a user