make protonmail notifications work again, cleaner UI, update to v2 of IMAP lib,

This commit is contained in:
lone-cloud 2026-02-03 03:25:48 -08:00
parent 051a13cb7a
commit e31ccc76c5
19 changed files with 438 additions and 366 deletions

View file

@ -27,5 +27,4 @@
# PROTON_IMAP_USERNAME=your-email@proton.me # PROTON_IMAP_USERNAME=your-email@proton.me
# PROTON_IMAP_PASSWORD=bridge-generated-password # PROTON_IMAP_PASSWORD=bridge-generated-password
# PROTON_BRIDGE_HOST=protonmail-bridge # PROTON_BRIDGE_HOST=protonmail-bridge
# PROTON_BRIDGE_PORT=143 # PROTON_BRIDGE_PORT=143
# PROTON_PRISM_TOPIC=Proton Mail

View file

@ -10,7 +10,7 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
@ -40,7 +40,7 @@ jobs:
fi fi
- name: Install golangci-lint - name: Install golangci-lint
uses: golangci/golangci-lint-action@v7 uses: golangci/golangci-lint-action@v9
with: with:
version: v2.8.0 version: v2.8.0
args: --timeout=5m args: --timeout=5m

View file

@ -14,13 +14,13 @@ jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}

View file

@ -10,7 +10,7 @@ RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo \ RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo \
-ldflags="-w -s -X main.version=$(git describe --tags --always) -X main.commit=$(git rev-parse --short HEAD)" \ -ldflags="-w -s -X main.version=$(cat VERSION 2>/dev/null || echo dev) -X main.commit=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" \
-o prism . -o prism .
FROM alpine:latest FROM alpine:latest

View file

@ -11,7 +11,7 @@ all: fmt lint build
build: build:
go build -ldflags="-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o $(BINARY_NAME) . go build -ldflags="-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o $(BINARY_NAME) .
run: build start: build
./$(BINARY_NAME) ./$(BINARY_NAME)
dev: dev:
@ -52,12 +52,15 @@ check-updates:
docker-build: docker-build:
docker build -t prism:$(VERSION) . docker build -t prism:$(VERSION) .
docker-run: docker-up:
docker compose -f docker-compose.dev.yml up -d docker compose -f docker-compose.dev.yml up -d
docker-down: docker-down:
docker compose -f docker-compose.dev.yml down docker compose -f docker-compose.dev.yml down
docker-up-proton:
docker compose -f docker-compose.dev.yml --profile protonmail up -d
release: release:
@if [ ! -f VERSION ]; then \ @if [ ! -f VERSION ]; then \
echo "Error: VERSION file not found"; \ echo "Error: VERSION file not found"; \

View file

@ -15,7 +15,6 @@ services:
- PROTON_IMAP_PASSWORD=${PROTON_IMAP_PASSWORD:-} - PROTON_IMAP_PASSWORD=${PROTON_IMAP_PASSWORD:-}
- PROTON_BRIDGE_HOST=protonmail-bridge - PROTON_BRIDGE_HOST=protonmail-bridge
- PROTON_BRIDGE_PORT=${PROTON_BRIDGE_PORT:-143} - PROTON_BRIDGE_PORT=${PROTON_BRIDGE_PORT:-143}
- PROTON_PRISM_TOPIC=${PROTON_PRISM_TOPIC:-Proton Mail}
volumes: volumes:
- signal-data:/root/.local/share/signal-cli - signal-data:/root/.local/share/signal-cli
- prism-data:/root/.local/share/prism - prism-data:/root/.local/share/prism

View file

@ -13,7 +13,6 @@ services:
- PROTON_IMAP_PASSWORD=${PROTON_IMAP_PASSWORD:-} - PROTON_IMAP_PASSWORD=${PROTON_IMAP_PASSWORD:-}
- PROTON_BRIDGE_HOST=protonmail-bridge - PROTON_BRIDGE_HOST=protonmail-bridge
- PROTON_BRIDGE_PORT=${PROTON_BRIDGE_PORT:-143} - PROTON_BRIDGE_PORT=${PROTON_BRIDGE_PORT:-143}
- PROTON_PRISM_TOPIC=${PROTON_PRISM_TOPIC:-Proton Mail}
volumes: volumes:
- signal-data:/root/.local/share/signal-cli - signal-data:/root/.local/share/signal-cli
- prism-data:/root/.local/share/prism - prism-data:/root/.local/share/prism

6
go.mod
View file

@ -3,7 +3,7 @@ module prism
go 1.25.6 go 1.25.6
require ( require (
github.com/emersion/go-imap v1.2.1 github.com/emersion/go-imap/v2 v2.0.0-beta.7
github.com/go-chi/chi/v5 v5.2.4 github.com/go-chi/chi/v5 v5.2.4
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.33 github.com/mattn/go-sqlite3 v1.14.33
@ -11,6 +11,6 @@ require (
) )
require ( require (
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect github.com/emersion/go-message v0.18.1 // indirect
golang.org/x/text v0.28.0 // indirect github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
) )

44
go.sum
View file

@ -1,19 +1,45 @@
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= github.com/emersion/go-imap/v2 v2.0.0-beta.7 h1:lNznYWa5uhMrngnSYEklzCeye4DBq9TEJ+pr0K593+8=
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= github.com/emersion/go-imap/v2 v2.0.0-beta.7/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk=
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View file

@ -1,60 +1,60 @@
:root { :root {
--bg-primary: #f5f5f5; --bg-primary: #f5f5f5;
--bg-secondary: #fdfdfd; --bg-secondary: #fdfdfd;
--text-primary: #000; --text-primary: #000;
--text-secondary: #666; --text-secondary: #666;
--border-color: rgba(0, 0, 0, 0.1); --border-color: rgba(0, 0, 0, 0.1);
--accent: #8159b8; --accent: #8159b8;
--success: #28a745; --success: #28a745;
--error: #dc3545; --error: #dc3545;
--spinner-track: #e0e0e0; --spinner-track: #e0e0e0;
--badge-signal-bg: #8159b8; --badge-signal-bg: #8159b8;
--badge-webhook-bg: rgba(40, 167, 69, 0.2); --badge-webhook-bg: rgba(40, 167, 69, 0.2);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { :root:not([data-theme="light"]) {
--bg-primary: #1a1a1a; --bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d; --bg-secondary: #2d2d2d;
--text-primary: #e0e0e0; --text-primary: #e0e0e0;
--text-secondary: #a0a0a0; --text-secondary: #a0a0a0;
--border-color: rgba(255, 255, 255, 0.1); --border-color: rgba(255, 255, 255, 0.1);
--success: #28a745; --success: #28a745;
--error: #ef4444; --error: #ef4444;
--spinner-track: #4a4a4a; --spinner-track: #4a4a4a;
--badge-signal-bg: #8159b8; --badge-signal-bg: #8159b8;
--badge-webhook-bg: #28a745; --badge-webhook-bg: #28a745;
} }
} }
[data-theme="dark"] { [data-theme="dark"] {
--bg-primary: #1a1a1a; --bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d; --bg-secondary: #2d2d2d;
--text-primary: #e0e0e0; --text-primary: #e0e0e0;
--text-secondary: #a0a0a0; --text-secondary: #a0a0a0;
--border-color: rgba(255, 255, 255, 0.1); --border-color: rgba(255, 255, 255, 0.1);
--success: #28a745; --success: #28a745;
--error: #ef4444; --error: #ef4444;
--spinner-track: #4a4a4a; --spinner-track: #4a4a4a;
--badge-signal-bg: #8159b8; --badge-signal-bg: #8159b8;
--badge-webhook-bg: #28a745; --badge-webhook-bg: #28a745;
} }
/* CSS Reset */ /* CSS Reset */
*, *,
*::before, *::before,
*::after { *::after {
box-sizing: border-box; box-sizing: border-box;
} }
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
body { body {
line-height: 1.5; line-height: 1.5;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
img, img,
@ -62,15 +62,15 @@ picture,
video, video,
canvas, canvas,
svg { svg {
display: block; display: block;
max-width: 100%; max-width: 100%;
} }
input, input,
button, button,
textarea, textarea,
select { select {
font: inherit; font: inherit;
} }
p, p,
@ -80,292 +80,298 @@ h3,
h4, h4,
h5, h5,
h6 { h6 {
overflow-wrap: break-word; overflow-wrap: break-word;
} }
/* Styles */ /* Styles */
body { body {
font-family: system-ui; font-family: system-ui;
max-width: 50rem; max-width: 50rem;
margin: 1.25rem auto; margin: 1.25rem auto;
padding: 0 1.25rem 1.25rem; padding: 0 1.25rem 1.25rem;
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
} }
.card { .card {
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 1.25rem; padding: 1.25rem;
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
box-shadow: 0 0.125rem 0.25rem var(--border-color); box-shadow: 0 0.125rem 0.25rem var(--border-color);
} }
h2 { h2 {
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.status { .status {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.status-item { .status-item {
padding: 0.625rem 1rem; padding: 0.625rem 1rem;
border-radius: 0.25rem; border-radius: 0.25rem;
font-weight: 500; font-weight: 500;
position: relative; position: relative;
} }
.status-item:has(.tooltip) { .status-item:has(.tooltip),
cursor: help; .channel-badge:has(.tooltip) {
cursor: help;
} }
.status-item .tooltip { .status-item .tooltip,
visibility: hidden; .channel-badge .tooltip {
opacity: 0; visibility: hidden;
position: absolute; opacity: 0;
bottom: 100%; position: absolute;
left: 50%; bottom: 100%;
transform: translateX(-50%); left: 50%;
margin-bottom: 0.5rem; transform: translateX(-50%);
background: var(--bg-secondary); margin-bottom: 0.5rem;
color: var(--text-primary); background: var(--bg-secondary);
padding: 0.375rem 0.625rem; color: var(--text-primary);
border-radius: 0.25rem; padding: 0.375rem 0.625rem;
border: 1px solid var(--border-color); border-radius: 0.25rem;
font-size: 0.875rem; border: 1px solid var(--border-color);
font-weight: 400; font-size: 0.875rem;
white-space: nowrap; font-weight: 400;
transition: opacity 0.2s; white-space: nowrap;
pointer-events: none; transition: opacity 0.2s;
z-index: 10; pointer-events: none;
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.2); z-index: 10;
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.2);
} }
.status-item .tooltip::after { .status-item .tooltip::after,
content: ""; .channel-badge .tooltip::after {
position: absolute; content: "";
top: 100%; position: absolute;
left: 50%; top: 100%;
transform: translateX(-50%); left: 50%;
border: 0.3125rem solid transparent; transform: translateX(-50%);
border-top-color: var(--bg-secondary); border: 0.3125rem solid transparent;
border-top-color: var(--bg-secondary);
} }
.status-item:hover .tooltip { .status-item:hover .tooltip,
visibility: visible; .channel-badge:hover .tooltip {
opacity: 1; visibility: visible;
opacity: 1;
} }
.theme-toggle { .theme-toggle {
float: right; display: block;
background: transparent; margin-left: auto;
border: none; background: transparent;
cursor: pointer; border: none;
font-size: 1.5rem; cursor: pointer;
padding: 0; font-size: 1.5rem;
line-height: 1; padding: 0;
opacity: 0.6; line-height: 1;
transition: opacity 0.2s; opacity: 0.6;
transition: opacity 0.2s;
} }
.theme-toggle:hover { .theme-toggle:hover {
opacity: 1; opacity: 1;
} }
.status-ok { .status-ok {
background: var(--success); background: var(--success);
color: white; color: white;
} }
.status-error { .status-error {
background: var(--error); background: var(--error);
color: white; color: white;
} }
.endpoint-list { .endpoint-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
max-height: 18.75rem; max-height: 18.75rem;
overflow-y: auto; overflow: visible;
} }
.endpoint-item { .endpoint-item {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0.75rem; padding: 0.75rem;
background: var(--bg-primary); background: var(--bg-primary);
border-radius: 0.25rem; border-radius: 0.25rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
gap: 1rem; gap: 1rem;
} }
.endpoint-info { .endpoint-info {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
} }
.endpoint-name { .endpoint-name {
font-size: 1.1em; font-size: 1.1em;
} }
.endpoint-channel { .endpoint-channel {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
font-size: 0.9em; font-size: 0.9em;
} }
.channel-badge { .channel-badge {
padding: 0.15rem 0.5rem; padding: 0.15rem 0.5rem;
border-radius: 0.25rem; border-radius: 0.25rem;
font-size: 0.85em; font-size: 0.85em;
font-weight: 500; font-weight: 500;
position: relative;
} }
.channel-signal { .channel-signal {
background: var(--badge-signal-bg); background: var(--badge-signal-bg);
color: white; color: white;
} }
.channel-webhook { .channel-webhook {
background: var(--badge-webhook-bg); background: var(--badge-webhook-bg);
color: white; color: white;
} }
.endpoint-detail { .endpoint-detail {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.85em; font-size: 0.85em;
} }
.endpoint-actions { .endpoint-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
} }
.channel-select { .channel-select {
padding: 0.375rem 0.5rem; padding: 0.375rem 0.5rem;
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-primary); color: var(--text-primary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 0.25rem; border-radius: 0.25rem;
cursor: pointer; cursor: pointer;
font-size: 0.9em; font-size: 0.9em;
} }
.btn-delete { .btn-delete {
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
background: var(--error); background: var(--error);
color: white; color: white;
border: none; border: none;
border-radius: 0.25rem; border-radius: 0.25rem;
cursor: pointer; cursor: pointer;
transition: filter 0.2s; transition: filter 0.2s;
white-space: nowrap; white-space: nowrap;
} }
.btn-delete:hover { .btn-delete:hover {
filter: brightness(0.9); filter: brightness(0.9);
} }
.btn-cancel { .btn-cancel {
margin-top: 1rem; margin-top: 1rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: var(--text-secondary); background: var(--text-secondary);
color: white; color: white;
border: none; border: none;
border-radius: 0.25rem; border-radius: 0.25rem;
cursor: pointer; cursor: pointer;
transition: filter 0.2s; transition: filter 0.2s;
} }
.btn-cancel:hover { .btn-cancel:hover {
filter: brightness(0.9); filter: brightness(0.9);
} }
.unlink-details { .unlink-details {
margin-top: 1rem; margin-top: 1rem;
} }
.unlink-summary { .unlink-summary {
cursor: pointer; cursor: pointer;
font-weight: bold; font-weight: bold;
} }
.unlink-instructions { .unlink-instructions {
margin-top: 1rem; margin-top: 1rem;
} }
.unlink-instructions ol { .unlink-instructions ol {
margin-left: 1.25rem; margin-left: 1.25rem;
} }
.qr-instructions { .qr-instructions {
font-size: 1.05em; font-size: 1.05em;
} }
.qr-container { .qr-container {
margin-top: 1rem; margin-top: 1rem;
width: 18.75rem; width: 18.75rem;
height: 18.75rem; height: 18.75rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.qr-image { .qr-image {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
} }
.link-button { .link-button {
display: inline-block; display: inline-block;
padding: 0.625rem 1.25rem; padding: 0.625rem 1.25rem;
background: var(--accent); background: var(--accent);
color: white; color: white;
text-decoration: none; text-decoration: none;
border-radius: 0.25rem; border-radius: 0.25rem;
margin-top: 0.625rem; margin-top: 0.625rem;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: filter 0.2s; transition: filter 0.2s;
} }
.link-button:hover { .link-button:hover {
filter: brightness(0.9); filter: brightness(0.9);
} }
.loading { .loading {
color: var(--text-secondary); color: var(--text-secondary);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
.spinner { .spinner {
width: 1.25rem; width: 1.25rem;
height: 1.25rem; height: 1.25rem;
border: 0.125rem solid var(--spinner-track); border: 0.125rem solid var(--spinner-track);
border-top-color: var(--accent); border-top-color: var(--accent);
border-radius: 50%; border-radius: 50%;
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }
@keyframes spin { @keyframes spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
.loading-subtitle { .loading-subtitle {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-secondary); color: var(--text-secondary);
margin-top: 0.5rem; margin-top: 0.5rem;
} }

View file

@ -30,10 +30,6 @@ type Config struct {
IMAPMaxReconnectDelay int IMAPMaxReconnectDelay int
IMAPMaxReconnectAttempts int IMAPMaxReconnectAttempts int
EndpointPrefixProton string
EndpointPrefixNtfy string
EndpointPrefixUP string
StoragePath string StoragePath string
} }
@ -53,7 +49,7 @@ func Load() (*Config, error) {
ProtonIMAPPassword: os.Getenv("PROTON_IMAP_PASSWORD"), ProtonIMAPPassword: os.Getenv("PROTON_IMAP_PASSWORD"),
ProtonBridgeHost: getEnvString("PROTON_BRIDGE_HOST", "protonmail-bridge"), ProtonBridgeHost: getEnvString("PROTON_BRIDGE_HOST", "protonmail-bridge"),
ProtonBridgePort: getEnvInt("PROTON_BRIDGE_PORT", 143), ProtonBridgePort: getEnvInt("PROTON_BRIDGE_PORT", 143),
ProtonPrismTopic: getEnvString("PROTON_PRISM_TOPIC", "Proton Mail"), ProtonPrismTopic: "Proton Mail",
IMAPInbox: "INBOX", IMAPInbox: "INBOX",
IMAPSeenFlag: "\\Seen", IMAPSeenFlag: "\\Seen",
@ -61,10 +57,6 @@ func Load() (*Config, error) {
IMAPMaxReconnectDelay: 300000, IMAPMaxReconnectDelay: 300000,
IMAPMaxReconnectAttempts: 50, IMAPMaxReconnectAttempts: 50,
EndpointPrefixProton: "proton-",
EndpointPrefixNtfy: "ntfy-",
EndpointPrefixUP: "up-",
StoragePath: getEnvString("STORAGE_PATH", "./data/prism.db"), StoragePath: getEnvString("STORAGE_PATH", "./data/prism.db"),
} }

View file

@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
"prism/service/signal" "prism/service/signal"
) )
@ -28,19 +27,23 @@ func NewDispatcher(store *Store, signalClient *signal.Client, logger *slog.Logge
func (d *Dispatcher) Send(endpoint string, notif Notification) error { func (d *Dispatcher) Send(endpoint string, notif Notification) error {
mapping, err := d.store.GetEndpointMapping(endpoint) mapping, err := d.store.GetEndpointMapping(endpoint)
if err != nil { if err != nil {
d.logger.Error("Failed to get endpoint mapping", "endpoint", endpoint, "error", err)
return fmt.Errorf("failed to get mapping: %w", err) return fmt.Errorf("failed to get mapping: %w", err)
} }
if mapping == nil { if mapping == nil {
appName := strings.TrimPrefix(endpoint, "ntfy-") d.logger.Info("No mapping found for endpoint, creating new registration", "endpoint", endpoint)
appName = strings.TrimPrefix(appName, "proton-")
if err := d.store.Register(endpoint, appName, ChannelSignal, nil, nil); err != nil { d.logger.Info("Registering new endpoint", "endpoint", endpoint, "appName", endpoint, "channel", ChannelSignal)
if err := d.store.Register(endpoint, endpoint, ChannelSignal, nil, nil); err != nil {
d.logger.Error("Failed to register endpoint", "endpoint", endpoint, "error", err)
return fmt.Errorf("failed to register endpoint: %w", err) return fmt.Errorf("failed to register endpoint: %w", err)
} }
mapping, err = d.store.GetEndpointMapping(endpoint) mapping, err = d.store.GetEndpointMapping(endpoint)
if err != nil { if err != nil {
d.logger.Error("Failed to get mapping after registration", "endpoint", endpoint, "error", err)
return fmt.Errorf("failed to get mapping after registration: %w", err) return fmt.Errorf("failed to get mapping after registration: %w", err)
} }
} }
@ -51,6 +54,7 @@ func (d *Dispatcher) Send(endpoint string, notif Notification) error {
case ChannelSignal: case ChannelSignal:
return d.sendSignal(mapping, notif) return d.sendSignal(mapping, notif)
default: default:
d.logger.Error("Unknown channel type", "channel", mapping.Channel)
return fmt.Errorf("unknown channel: %s", mapping.Channel) return fmt.Errorf("unknown channel: %s", mapping.Channel)
} }
} }
@ -58,22 +62,27 @@ func (d *Dispatcher) Send(endpoint string, notif Notification) error {
func (d *Dispatcher) sendSignal(mapping *Mapping, notif Notification) error { func (d *Dispatcher) sendSignal(mapping *Mapping, notif Notification) error {
groupID := mapping.GroupID groupID := mapping.GroupID
if groupID == nil || *groupID == "" { if groupID == nil || *groupID == "" {
d.logger.Info("No group ID found, creating new group", "appName", mapping.AppName)
newGroupID, err := d.createGroup(mapping.AppName) newGroupID, err := d.createGroup(mapping.AppName)
if err != nil { if err != nil {
d.logger.Error("Failed to create group", "appName", mapping.AppName, "error", err)
return fmt.Errorf("failed to create group: %w", err) return fmt.Errorf("failed to create group: %w", err)
} }
groupID = &newGroupID groupID = &newGroupID
d.logger.Info("Created new group", "appName", mapping.AppName, "groupID", *groupID)
if err := d.store.UpdateGroupID(mapping.Endpoint, *groupID); err != nil { if err := d.store.UpdateGroupID(mapping.Endpoint, *groupID); err != nil {
d.logger.Warn("Failed to update group ID", "error", err) d.logger.Warn("Failed to update group ID", "error", err)
} }
} }
if err := d.sendGroupMessage(*groupID, notif); err != nil { if err := d.sendGroupMessage(*groupID, notif); err != nil {
d.logger.Error("Failed to send group message", "groupID", *groupID, "error", err)
return fmt.Errorf("failed to send group message: %w", err) return fmt.Errorf("failed to send group message: %w", err)
} }
d.logger.Debug("Sent Signal notification", "app", mapping.AppName, "message", notif.Message)
return nil return nil
} }
@ -113,15 +122,17 @@ func (d *Dispatcher) createGroup(appName string) (string, error) {
func (d *Dispatcher) sendGroupMessage(groupID string, notif Notification) error { func (d *Dispatcher) sendGroupMessage(groupID string, notif Notification) error {
account, err := d.signalClient.GetLinkedAccount() account, err := d.signalClient.GetLinkedAccount()
if err != nil { if err != nil {
d.logger.Error("Failed to get linked account", "error", err)
return fmt.Errorf("failed to get linked account: %w", err) return fmt.Errorf("failed to get linked account: %w", err)
} }
if account == nil { if account == nil {
d.logger.Error("No linked Signal account found")
return fmt.Errorf("no linked Signal account") return fmt.Errorf("no linked Signal account")
} }
message := notif.Message message := notif.Message
if notif.Title != "" { if notif.Title != "" {
message = fmt.Sprintf("%s\n\n%s", notif.Title, notif.Message) message = fmt.Sprintf("%s\n%s", notif.Title, notif.Message)
} }
params := map[string]interface{}{ params := map[string]interface{}{
@ -131,6 +142,9 @@ func (d *Dispatcher) sendGroupMessage(groupID string, notif Notification) error
} }
_, err = d.signalClient.CallWithAccount("send", params, account.Number) _, err = d.signalClient.CallWithAccount("send", params, account.Number)
if err != nil {
d.logger.Error("Signal send API call failed", "error", err)
}
return err return err
} }

View file

@ -5,20 +5,33 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/emersion/go-imap/client" "github.com/emersion/go-imap/v2/imapclient"
) )
func (m *Monitor) connect() error { func (m *Monitor) connect() error {
addr := fmt.Sprintf("%s:%d", m.cfg.ProtonBridgeHost, m.cfg.ProtonBridgePort) addr := fmt.Sprintf("%s:%d", m.cfg.ProtonBridgeHost, m.cfg.ProtonBridgePort)
c, err := client.Dial(addr)
options := &imapclient.Options{
UnilateralDataHandler: &imapclient.UnilateralDataHandler{
Mailbox: func(data *imapclient.UnilateralDataMailbox) {
if data.NumMessages != nil {
select {
case m.newMessagesChan <- struct{}{}:
default:
m.logger.Warn("New messages channel full, skipping signal")
}
}
},
},
}
c, err := imapclient.DialInsecure(addr, options)
if err != nil { if err != nil {
return fmt.Errorf("failed to dial: %w", err) return fmt.Errorf("failed to dial: %w", err)
} }
if err := c.Login(m.cfg.ProtonIMAPUsername, m.cfg.ProtonIMAPPassword); err != nil { if err := c.Login(m.cfg.ProtonIMAPUsername, m.cfg.ProtonIMAPPassword).Wait(); err != nil {
if logoutErr := c.Logout(); logoutErr != nil { c.Close()
m.logger.Error("Logout failed", "error", logoutErr)
}
return fmt.Errorf("failed to login: %w", err) return fmt.Errorf("failed to login: %w", err)
} }
@ -28,60 +41,39 @@ func (m *Monitor) connect() error {
} }
func (m *Monitor) monitor(ctx context.Context) error { func (m *Monitor) monitor(ctx context.Context) error {
_, err := m.client.Select(m.cfg.IMAPInbox, false) selectCmd := m.client.Select(m.cfg.IMAPInbox, nil)
_, err := selectCmd.Wait()
if err != nil { if err != nil {
return fmt.Errorf("failed to select inbox: %w", err) return fmt.Errorf("failed to select inbox: %w", err)
} }
if m.monitorStartTime.IsZero() { if m.monitorStartTime.IsZero() {
m.monitorStartTime = time.Now() m.monitorStartTime = time.Now()
m.logger.Info("Monitor start time set", "time", m.monitorStartTime) m.logger.Info("Proton Mail monitor start time set", "time", m.monitorStartTime)
} }
updates := make(chan client.Update, 10) idleCmd, err := m.client.Idle()
m.client.Updates = updates if err != nil {
return fmt.Errorf("failed to start idle: %w", err)
stop := make(chan struct{}) }
defer close(stop)
idleErr := make(chan error, 1)
go func() {
idleErr <- m.client.Idle(stop, nil)
}()
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
idleCmd.Close()
return ctx.Err() return ctx.Err()
case update := <-updates: case <-m.newMessagesChan:
switch u := update.(type) { idleCmd.Close()
case *client.MailboxUpdate:
if u.Mailbox.UnseenSeqNum > 0 { if err := m.sendNotification(); err != nil {
if err := m.handleNewMessages(); err != nil { m.logger.Error("Failed to send notification", "error", err)
m.logger.Error("Failed to handle new messages", "error", err)
}
}
} }
case <-ticker.C: idleCmd, err = m.client.Idle()
close(stop) if err != nil {
<-idleErr return fmt.Errorf("failed to restart idle: %w", err)
if err := m.client.Noop(); err != nil {
return fmt.Errorf("noop failed: %w", err)
} }
stop = make(chan struct{})
go func() {
idleErr <- m.client.Idle(stop, nil)
}()
case err := <-idleErr:
return fmt.Errorf("idle ended: %w", err)
} }
} }
} }

View file

@ -5,74 +5,67 @@ import (
"prism/service/notification" "prism/service/notification"
"github.com/emersion/go-imap" "github.com/emersion/go-imap/v2"
) )
func (m *Monitor) handleNewMessages() error { func (m *Monitor) sendNotification() error {
criteria := imap.NewSearchCriteria() selectData, err := m.client.Select(m.cfg.IMAPInbox, nil).Wait()
criteria.WithoutFlags = []string{m.cfg.IMAPSeenFlag}
uids, err := m.client.UidSearch(criteria)
if err != nil { if err != nil {
return fmt.Errorf("search failed: %w", err) m.logger.Error("Failed to select inbox", "error", err)
return err
} }
if len(uids) == 0 { if selectData.NumMessages == 0 {
m.logger.Warn("No messages in mailbox")
return nil return nil
} }
seqSet := new(imap.SeqSet) latestSeq := selectData.NumMessages
seqSet.AddNum(uids...)
messages := make(chan *imap.Message, 10) seqSet := imap.SeqSetNum(latestSeq)
done := make(chan error, 1)
go func() { fetchOptions := &imap.FetchOptions{
done <- m.client.UidFetch(seqSet, []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}, messages) Envelope: true,
}() UID: true,
for msg := range messages {
if err := m.processMessage(msg); err != nil {
m.logger.Error("Failed to process message", "error", err)
}
} }
if err := <-done; err != nil { fetchCmd := m.client.Fetch(seqSet, fetchOptions)
return fmt.Errorf("fetch failed: %w", err) defer fetchCmd.Close()
}
return nil msg := fetchCmd.Next()
} if msg == nil {
m.logger.Warn("No message found to send notification")
func (m *Monitor) processMessage(msg *imap.Message) error {
if msg.Envelope == nil {
return nil return nil
} }
if msg.Envelope.Date.Before(m.monitorStartTime) { msgData, err := msg.Collect()
m.logger.Debug("Skipping old email", "date", msg.Envelope.Date, "subject", msg.Envelope.Subject) if err != nil {
m.logger.Error("Failed to collect message", "error", err)
return err
}
if msgData.Envelope == nil {
m.logger.Warn("Message has no envelope")
return nil return nil
} }
var from string var from string
if len(msg.Envelope.From) > 0 { if len(msgData.Envelope.From) > 0 {
addr := msg.Envelope.From[0] addr := msgData.Envelope.From[0]
if addr.PersonalName != "" { if addr.Name != "" {
from = addr.PersonalName from = addr.Name
} else { } else {
from = addr.Address() from = addr.Addr()
} }
} else { } else {
from = "Unknown sender" from = "Unknown sender"
} }
subject := msg.Envelope.Subject subject := msgData.Envelope.Subject
if subject == "" { if subject == "" {
subject = "No subject" subject = "No subject"
} }
endpoint := m.cfg.EndpointPrefixProton + m.cfg.ProtonPrismTopic
notif := notification.Notification{ notif := notification.Notification{
Title: from, Title: from,
Message: subject, Message: subject,
@ -82,18 +75,18 @@ func (m *Monitor) processMessage(msg *imap.Message) error {
Endpoint: "/api/proton-mail/mark-read", Endpoint: "/api/proton-mail/mark-read",
Method: "POST", Method: "POST",
Data: map[string]interface{}{ Data: map[string]interface{}{
"uid": msg.Uid, "uid": msgData.UID,
}, },
}, },
}, },
} }
if err := m.dispatcher.Send(endpoint, notif); err != nil { if err := m.dispatcher.Send(m.cfg.ProtonPrismTopic, notif); err != nil {
m.logger.Error("Failed to send notification", "endpoint", endpoint, "error", err) m.logger.Error("Failed to send notification", "error", err)
return err return err
} }
m.logger.Info("Processed Proton Mail notification", "from", from, "subject", subject) m.logger.Info("Sent ProtonMail notification", "from", from, "subject", subject, "uid", msgData.UID)
return nil return nil
} }
@ -102,11 +95,28 @@ func (m *Monitor) MarkAsRead(uid uint32) error {
return fmt.Errorf("not connected") return fmt.Errorf("not connected")
} }
seqSet := new(imap.SeqSet) uidSet := imap.UIDSet{}
seqSet.AddNum(uid) uidSet.AddNum(imap.UID(uid))
item := imap.FormatFlagsOp(imap.AddFlags, true) storeFlags := &imap.StoreFlags{
flags := []interface{}{m.cfg.IMAPSeenFlag} Op: imap.StoreFlagsAdd,
Flags: []imap.Flag{imap.FlagSeen},
Silent: false,
}
return m.client.UidStore(seqSet, item, flags, nil) storeCmd := m.client.Store(uidSet, storeFlags, nil)
for {
msg := storeCmd.Next()
if msg == nil {
break
}
}
if err := storeCmd.Close(); err != nil {
return fmt.Errorf("failed to mark message as read: %w", err)
}
m.logger.Info("Marked message as read", "uid", uid)
return nil
} }

View file

@ -8,22 +8,24 @@ import (
"prism/service/config" "prism/service/config"
"prism/service/notification" "prism/service/notification"
"github.com/emersion/go-imap/client" "github.com/emersion/go-imap/v2/imapclient"
) )
type Monitor struct { type Monitor struct {
cfg *config.Config cfg *config.Config
dispatcher *notification.Dispatcher dispatcher *notification.Dispatcher
logger *slog.Logger logger *slog.Logger
client *client.Client client *imapclient.Client
monitorStartTime time.Time monitorStartTime time.Time
newMessagesChan chan struct{}
} }
func NewMonitor(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger) *Monitor { func NewMonitor(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger) *Monitor {
return &Monitor{ return &Monitor{
cfg: cfg, cfg: cfg,
dispatcher: dispatcher, dispatcher: dispatcher,
logger: logger, logger: logger,
newMessagesChan: make(chan struct{}, 10),
} }
} }

View file

@ -34,12 +34,14 @@ func (s *Server) handleFragmentHealth(w http.ResponseWriter, r *http.Request) {
if hasProton { if hasProton {
protonStatus := "Disconnected" protonStatus := "Disconnected"
protonClass := "status-error" protonClass := "status-error"
protonTooltip := ""
if s.protonMonitor.IsConnected() { if s.protonMonitor.IsConnected() {
protonStatus = "Connected" protonStatus = "Connected"
protonClass = "status-ok" protonClass = "status-ok"
protonTooltip = fmt.Sprintf(`<span class="tooltip">%s</span>`, s.cfg.ProtonIMAPUsername)
} }
html += fmt.Sprintf(` html += fmt.Sprintf(`
<div class="status-item %s">Proton Mail: %s</div>`, protonClass, protonStatus) <div class="status-item %s">Proton Mail: %s%s</div>`, protonClass, protonStatus, protonTooltip)
} }
html += `</div> html += `</div>
@ -116,27 +118,29 @@ func (s *Server) handleFragmentEndpoints(w http.ResponseWriter, r *http.Request)
isSignal := m.Channel == notification.ChannelSignal isSignal := m.Channel == notification.ChannelSignal
isWebhook := m.Channel == notification.ChannelWebhook isWebhook := m.Channel == notification.ChannelWebhook
channelBadge := "Signal" channelBadge := "Signal"
channelTooltip := ""
if isWebhook { if isWebhook {
channelBadge = "Webhook" channelBadge = "Webhook"
} }
if isSignal && m.GroupID != nil {
channelTooltip = fmt.Sprintf(`<span class="tooltip">Group ID: %s</span>`, *m.GroupID)
}
if isWebhook && m.UpEndpoint != nil {
channelTooltip = fmt.Sprintf(`<span class="tooltip">%s</span>`, *m.UpEndpoint)
}
html := fmt.Sprintf(`<li class="endpoint-item"> html := fmt.Sprintf(`<li class="endpoint-item">
<div class="endpoint-info"> <div class="endpoint-info">
<div class="endpoint-name"><strong>%s</strong></div> <div class="endpoint-name"><strong>%s</strong></div>
<div class="endpoint-channel"> <div class="endpoint-channel">
<span class="channel-badge channel-%s">%s</span>`, m.AppName, m.Channel, channelBadge) <span class="channel-badge channel-%s">%s%s</span>`, m.AppName, m.Channel, channelBadge, channelTooltip)
if m.UpEndpoint != nil && isWebhook { if m.UpEndpoint != nil && isWebhook {
// Extract hostname from webhook URL
if u, err := url.Parse(*m.UpEndpoint); err == nil { if u, err := url.Parse(*m.UpEndpoint); err == nil {
html += fmt.Sprintf(`<span class="endpoint-detail">%s</span>`, u.Hostname()) html += fmt.Sprintf(`<span class="endpoint-detail">%s</span>`, u.Hostname())
} }
} }
if m.GroupID != nil && isSignal {
html += fmt.Sprintf(`<span class="endpoint-detail">Groud ID: %s</span>`, *m.GroupID)
}
html += `</div></div><div class="endpoint-actions">` html += `</div></div><div class="endpoint-actions">`
if m.UpEndpoint != nil { if m.UpEndpoint != nil {

View file

@ -63,15 +63,13 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
title = "" title = ""
} }
fullEndpoint := s.cfg.EndpointPrefixNtfy + topic
notif := notification.Notification{ notif := notification.Notification{
Title: title, Title: title,
Message: message, Message: message,
} }
if err := s.dispatcher.Send(fullEndpoint, notif); err != nil { if err := s.dispatcher.Send(topic, notif); err != nil {
s.logger.Error("Failed to send notification", "endpoint", fullEndpoint, "error", err) s.logger.Error("Failed to send notification", "endpoint", topic, "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }

View file

@ -34,9 +34,7 @@ func (s *Server) handleWebhookRegister(w http.ResponseWriter, r *http.Request) {
return return
} }
endpoint := s.cfg.EndpointPrefixUP + req.AppName if err := s.store.Register(req.AppName, req.AppName, notification.ChannelWebhook, nil, &req.UpEndpoint); err != nil {
if err := s.store.Register(endpoint, req.AppName, notification.ChannelWebhook, nil, &req.UpEndpoint); err != nil {
s.logger.Error("Failed to register webhook endpoint", "error", err) s.logger.Error("Failed to register webhook endpoint", "error", err)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
@ -48,7 +46,7 @@ func (s *Server) handleWebhookRegister(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
response := map[string]string{ response := map[string]string{
"endpoint": endpoint, "endpoint": req.AppName,
"appName": req.AppName, "appName": req.AppName,
"channel": "webhook", "channel": "webhook",
} }

View file

@ -7,16 +7,46 @@ import (
) )
func FormatPhoneNumber(number string) string { func FormatPhoneNumber(number string) string {
cleaned := strings.ReplaceAll(number, " ", "") if number == "" {
cleaned = strings.ReplaceAll(cleaned, "-", "") return number
cleaned = strings.ReplaceAll(cleaned, "(", "")
cleaned = strings.ReplaceAll(cleaned, ")", "")
if !strings.HasPrefix(cleaned, "+") {
cleaned = "+" + cleaned
} }
return cleaned // US/Canada: +1 (234) 567-8901
if strings.HasPrefix(number, "+1") && len(number) == 12 {
return fmt.Sprintf("+1 (%s) %s-%s", number[2:5], number[5:8], number[8:])
}
// International: +XX XXXX XXXX
if strings.HasPrefix(number, "+") && len(number) > 4 {
digits := number[1:]
var countryCode, rest string
for i := 1; i <= 3 && i < len(digits); i++ {
if digits[i] < '0' || digits[i] > '9' {
break
}
countryCode = digits[:i+1]
rest = digits[i+1:]
}
if len(rest) >= 3 {
var parts []string
for len(rest) > 0 {
size := 3
if len(rest) == 4 || len(rest) == 8 {
size = 4
}
if size > len(rest) {
size = len(rest)
}
parts = append(parts, rest[:size])
rest = rest[size:]
}
return "+" + countryCode + " " + strings.Join(parts, " ")
}
}
return number
} }
func FormatUptime(d time.Duration) string { func FormatUptime(d time.Duration) string {