From e31ccc76c5df24105b35f488cf605ecaeaa374f5 Mon Sep 17 00:00:00 2001 From: lone-cloud Date: Tue, 3 Feb 2026 03:25:48 -0800 Subject: [PATCH] make protonmail notifications work again, cleaner UI, update to v2 of IMAP lib, --- .env.example | 3 +- .github/workflows/ci.yml | 4 +- .github/workflows/release.yml | 6 +- Dockerfile | 2 +- Makefile | 7 +- docker-compose.dev.yml | 1 - docker-compose.yml | 1 - go.mod | 6 +- go.sum | 44 ++- public/index.css | 424 ++++++++++++++-------------- service/config/config.go | 10 +- service/notification/dispatcher.go | 26 +- service/proton/connection.go | 78 +++-- service/proton/email.go | 104 ++++--- service/proton/monitor.go | 12 +- service/server/handlers_fragment.go | 18 +- service/server/handlers_ntfy.go | 6 +- service/server/handlers_webhook.go | 6 +- service/util/format.go | 46 ++- 19 files changed, 438 insertions(+), 366 deletions(-) diff --git a/.env.example b/.env.example index 3141fac..6e91c82 100644 --- a/.env.example +++ b/.env.example @@ -27,5 +27,4 @@ # PROTON_IMAP_USERNAME=your-email@proton.me # PROTON_IMAP_PASSWORD=bridge-generated-password # PROTON_BRIDGE_HOST=protonmail-bridge -# PROTON_BRIDGE_PORT=143 -# PROTON_PRISM_TOPIC=Proton Mail \ No newline at end of file +# PROTON_BRIDGE_PORT=143 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2848df4..cbdd67b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 @@ -40,7 +40,7 @@ jobs: fi - name: Install golangci-lint - uses: golangci/golangci-lint-action@v7 + uses: golangci/golangci-lint-action@v9 with: version: v2.8.0 args: --timeout=5m diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0af75cc..6e28598 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,13 +14,13 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@v3 - name: Login to GitHub Container Registry - uses: docker/login-action@v4 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/Dockerfile b/Dockerfile index c172f20..df66dd7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ RUN go mod download COPY . . 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 . FROM alpine:latest diff --git a/Makefile b/Makefile index efa2511..5a0bae4 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ all: fmt lint build build: go build -ldflags="-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o $(BINARY_NAME) . -run: build +start: build ./$(BINARY_NAME) dev: @@ -52,12 +52,15 @@ check-updates: docker-build: docker build -t prism:$(VERSION) . -docker-run: +docker-up: docker compose -f docker-compose.dev.yml up -d docker-down: docker compose -f docker-compose.dev.yml down +docker-up-proton: + docker compose -f docker-compose.dev.yml --profile protonmail up -d + release: @if [ ! -f VERSION ]; then \ echo "Error: VERSION file not found"; \ diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 86e191b..22ddba1 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -15,7 +15,6 @@ services: - PROTON_IMAP_PASSWORD=${PROTON_IMAP_PASSWORD:-} - PROTON_BRIDGE_HOST=protonmail-bridge - PROTON_BRIDGE_PORT=${PROTON_BRIDGE_PORT:-143} - - PROTON_PRISM_TOPIC=${PROTON_PRISM_TOPIC:-Proton Mail} volumes: - signal-data:/root/.local/share/signal-cli - prism-data:/root/.local/share/prism diff --git a/docker-compose.yml b/docker-compose.yml index 172a54b..f13f787 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,6 @@ services: - PROTON_IMAP_PASSWORD=${PROTON_IMAP_PASSWORD:-} - PROTON_BRIDGE_HOST=protonmail-bridge - PROTON_BRIDGE_PORT=${PROTON_BRIDGE_PORT:-143} - - PROTON_PRISM_TOPIC=${PROTON_PRISM_TOPIC:-Proton Mail} volumes: - signal-data:/root/.local/share/signal-cli - prism-data:/root/.local/share/prism diff --git a/go.mod b/go.mod index a26b89e..bad3687 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module prism go 1.25.6 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/joho/godotenv v1.5.1 github.com/mattn/go-sqlite3 v1.14.33 @@ -11,6 +11,6 @@ require ( ) require ( - github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect - golang.org/x/text v0.28.0 // indirect + github.com/emersion/go-message v0.18.1 // indirect + github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect ) diff --git a/go.sum b/go.sum index f7d04bc..9058e13 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,45 @@ -github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= -github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= -github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= -github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= -github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= -github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= +github.com/emersion/go-imap/v2 v2.0.0-beta.7 h1:lNznYWa5uhMrngnSYEklzCeye4DBq9TEJ+pr0K593+8= +github.com/emersion/go-imap/v2 v2.0.0-beta.7/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk= +github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E= +github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= +github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY= +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/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 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/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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +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/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-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= diff --git a/public/index.css b/public/index.css index 00d94fd..16df541 100644 --- a/public/index.css +++ b/public/index.css @@ -1,60 +1,60 @@ :root { - --bg-primary: #f5f5f5; - --bg-secondary: #fdfdfd; - --text-primary: #000; - --text-secondary: #666; - --border-color: rgba(0, 0, 0, 0.1); - --accent: #8159b8; - --success: #28a745; - --error: #dc3545; - --spinner-track: #e0e0e0; - --badge-signal-bg: #8159b8; - --badge-webhook-bg: rgba(40, 167, 69, 0.2); + --bg-primary: #f5f5f5; + --bg-secondary: #fdfdfd; + --text-primary: #000; + --text-secondary: #666; + --border-color: rgba(0, 0, 0, 0.1); + --accent: #8159b8; + --success: #28a745; + --error: #dc3545; + --spinner-track: #e0e0e0; + --badge-signal-bg: #8159b8; + --badge-webhook-bg: rgba(40, 167, 69, 0.2); } @media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) { - --bg-primary: #1a1a1a; - --bg-secondary: #2d2d2d; - --text-primary: #e0e0e0; - --text-secondary: #a0a0a0; - --border-color: rgba(255, 255, 255, 0.1); - --success: #28a745; - --error: #ef4444; - --spinner-track: #4a4a4a; - --badge-signal-bg: #8159b8; - --badge-webhook-bg: #28a745; - } + :root:not([data-theme="light"]) { + --bg-primary: #1a1a1a; + --bg-secondary: #2d2d2d; + --text-primary: #e0e0e0; + --text-secondary: #a0a0a0; + --border-color: rgba(255, 255, 255, 0.1); + --success: #28a745; + --error: #ef4444; + --spinner-track: #4a4a4a; + --badge-signal-bg: #8159b8; + --badge-webhook-bg: #28a745; + } } [data-theme="dark"] { - --bg-primary: #1a1a1a; - --bg-secondary: #2d2d2d; - --text-primary: #e0e0e0; - --text-secondary: #a0a0a0; - --border-color: rgba(255, 255, 255, 0.1); - --success: #28a745; - --error: #ef4444; - --spinner-track: #4a4a4a; - --badge-signal-bg: #8159b8; - --badge-webhook-bg: #28a745; + --bg-primary: #1a1a1a; + --bg-secondary: #2d2d2d; + --text-primary: #e0e0e0; + --text-secondary: #a0a0a0; + --border-color: rgba(255, 255, 255, 0.1); + --success: #28a745; + --error: #ef4444; + --spinner-track: #4a4a4a; + --badge-signal-bg: #8159b8; + --badge-webhook-bg: #28a745; } /* CSS Reset */ *, *::before, *::after { - box-sizing: border-box; + box-sizing: border-box; } * { - margin: 0; - padding: 0; + margin: 0; + padding: 0; } body { - line-height: 1.5; - -webkit-font-smoothing: antialiased; + line-height: 1.5; + -webkit-font-smoothing: antialiased; } img, @@ -62,15 +62,15 @@ picture, video, canvas, svg { - display: block; - max-width: 100%; + display: block; + max-width: 100%; } input, button, textarea, select { - font: inherit; + font: inherit; } p, @@ -80,292 +80,298 @@ h3, h4, h5, h6 { - overflow-wrap: break-word; + overflow-wrap: break-word; } /* Styles */ body { - font-family: system-ui; - max-width: 50rem; - margin: 1.25rem auto; - padding: 0 1.25rem 1.25rem; - background: var(--bg-primary); - color: var(--text-primary); + font-family: system-ui; + max-width: 50rem; + margin: 1.25rem auto; + padding: 0 1.25rem 1.25rem; + background: var(--bg-primary); + color: var(--text-primary); } .card { - background: var(--bg-secondary); - border-radius: 0.5rem; - padding: 1.25rem; - margin-bottom: 1.25rem; - box-shadow: 0 0.125rem 0.25rem var(--border-color); + background: var(--bg-secondary); + border-radius: 0.5rem; + padding: 1.25rem; + margin-bottom: 1.25rem; + box-shadow: 0 0.125rem 0.25rem var(--border-color); } h2 { - margin-bottom: 1.25rem; + margin-bottom: 1.25rem; } .status { - display: flex; - gap: 1rem; - flex-wrap: wrap; + display: flex; + gap: 1rem; + flex-wrap: wrap; } .status-item { - padding: 0.625rem 1rem; - border-radius: 0.25rem; - font-weight: 500; - position: relative; + padding: 0.625rem 1rem; + border-radius: 0.25rem; + font-weight: 500; + position: relative; } -.status-item:has(.tooltip) { - cursor: help; +.status-item:has(.tooltip), +.channel-badge:has(.tooltip) { + cursor: help; } -.status-item .tooltip { - visibility: hidden; - opacity: 0; - position: absolute; - bottom: 100%; - left: 50%; - transform: translateX(-50%); - margin-bottom: 0.5rem; - background: var(--bg-secondary); - color: var(--text-primary); - padding: 0.375rem 0.625rem; - border-radius: 0.25rem; - border: 1px solid var(--border-color); - font-size: 0.875rem; - font-weight: 400; - white-space: nowrap; - transition: opacity 0.2s; - pointer-events: none; - z-index: 10; - box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.2); +.status-item .tooltip, +.channel-badge .tooltip { + visibility: hidden; + opacity: 0; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + margin-bottom: 0.5rem; + background: var(--bg-secondary); + color: var(--text-primary); + padding: 0.375rem 0.625rem; + border-radius: 0.25rem; + border: 1px solid var(--border-color); + font-size: 0.875rem; + font-weight: 400; + white-space: nowrap; + transition: opacity 0.2s; + pointer-events: none; + z-index: 10; + box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.2); } -.status-item .tooltip::after { - content: ""; - position: absolute; - top: 100%; - left: 50%; - transform: translateX(-50%); - border: 0.3125rem solid transparent; - border-top-color: var(--bg-secondary); +.status-item .tooltip::after, +.channel-badge .tooltip::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 0.3125rem solid transparent; + border-top-color: var(--bg-secondary); } -.status-item:hover .tooltip { - visibility: visible; - opacity: 1; +.status-item:hover .tooltip, +.channel-badge:hover .tooltip { + visibility: visible; + opacity: 1; } .theme-toggle { - float: right; - background: transparent; - border: none; - cursor: pointer; - font-size: 1.5rem; - padding: 0; - line-height: 1; - opacity: 0.6; - transition: opacity 0.2s; + display: block; + margin-left: auto; + background: transparent; + border: none; + cursor: pointer; + font-size: 1.5rem; + padding: 0; + line-height: 1; + opacity: 0.6; + transition: opacity 0.2s; } .theme-toggle:hover { - opacity: 1; + opacity: 1; } .status-ok { - background: var(--success); - color: white; + background: var(--success); + color: white; } .status-error { - background: var(--error); - color: white; + background: var(--error); + color: white; } .endpoint-list { - list-style: none; - padding: 0; - max-height: 18.75rem; - overflow-y: auto; + list-style: none; + padding: 0; + max-height: 18.75rem; + overflow: visible; } .endpoint-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.75rem; - background: var(--bg-primary); - border-radius: 0.25rem; - margin-bottom: 0.5rem; - gap: 1rem; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem; + background: var(--bg-primary); + border-radius: 0.25rem; + margin-bottom: 0.5rem; + gap: 1rem; } .endpoint-info { - flex: 1; - display: flex; - flex-direction: column; - gap: 0.25rem; + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; } .endpoint-name { - font-size: 1.1em; + font-size: 1.1em; } .endpoint-channel { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.9em; + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9em; } .channel-badge { - padding: 0.15rem 0.5rem; - border-radius: 0.25rem; - font-size: 0.85em; - font-weight: 500; + padding: 0.15rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.85em; + font-weight: 500; + position: relative; } .channel-signal { - background: var(--badge-signal-bg); - color: white; + background: var(--badge-signal-bg); + color: white; } .channel-webhook { - background: var(--badge-webhook-bg); - color: white; + background: var(--badge-webhook-bg); + color: white; } .endpoint-detail { - color: var(--text-secondary); - font-size: 0.85em; + color: var(--text-secondary); + font-size: 0.85em; } .endpoint-actions { - display: flex; - gap: 0.5rem; - align-items: center; + display: flex; + gap: 0.5rem; + align-items: center; } .channel-select { - padding: 0.375rem 0.5rem; - background: var(--bg-secondary); - color: var(--text-primary); - border: 1px solid var(--border-color); - border-radius: 0.25rem; - cursor: pointer; - font-size: 0.9em; + padding: 0.375rem 0.5rem; + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.9em; } .btn-delete { - padding: 0.375rem 0.75rem; - background: var(--error); - color: white; - border: none; - border-radius: 0.25rem; - cursor: pointer; - transition: filter 0.2s; - white-space: nowrap; + padding: 0.375rem 0.75rem; + background: var(--error); + color: white; + border: none; + border-radius: 0.25rem; + cursor: pointer; + transition: filter 0.2s; + white-space: nowrap; } .btn-delete:hover { - filter: brightness(0.9); + filter: brightness(0.9); } .btn-cancel { - margin-top: 1rem; - padding: 0.5rem 1rem; - background: var(--text-secondary); - color: white; - border: none; - border-radius: 0.25rem; - cursor: pointer; - transition: filter 0.2s; + margin-top: 1rem; + padding: 0.5rem 1rem; + background: var(--text-secondary); + color: white; + border: none; + border-radius: 0.25rem; + cursor: pointer; + transition: filter 0.2s; } .btn-cancel:hover { - filter: brightness(0.9); + filter: brightness(0.9); } .unlink-details { - margin-top: 1rem; + margin-top: 1rem; } .unlink-summary { - cursor: pointer; - font-weight: bold; + cursor: pointer; + font-weight: bold; } .unlink-instructions { - margin-top: 1rem; + margin-top: 1rem; } .unlink-instructions ol { - margin-left: 1.25rem; + margin-left: 1.25rem; } .qr-instructions { - font-size: 1.05em; + font-size: 1.05em; } .qr-container { - margin-top: 1rem; - width: 18.75rem; - height: 18.75rem; - display: flex; - align-items: center; - justify-content: center; + margin-top: 1rem; + width: 18.75rem; + height: 18.75rem; + display: flex; + align-items: center; + justify-content: center; } .qr-image { - width: 100%; - height: 100%; - object-fit: contain; + width: 100%; + height: 100%; + object-fit: contain; } .link-button { - display: inline-block; - padding: 0.625rem 1.25rem; - background: var(--accent); - color: white; - text-decoration: none; - border-radius: 0.25rem; - margin-top: 0.625rem; - border: none; - cursor: pointer; - transition: filter 0.2s; + display: inline-block; + padding: 0.625rem 1.25rem; + background: var(--accent); + color: white; + text-decoration: none; + border-radius: 0.25rem; + margin-top: 0.625rem; + border: none; + cursor: pointer; + transition: filter 0.2s; } .link-button:hover { - filter: brightness(0.9); + filter: brightness(0.9); } .loading { - color: var(--text-secondary); - display: flex; - align-items: center; - gap: 0.5rem; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 0.5rem; } .spinner { - width: 1.25rem; - height: 1.25rem; - border: 0.125rem solid var(--spinner-track); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; + width: 1.25rem; + height: 1.25rem; + border: 0.125rem solid var(--spinner-track); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; } @keyframes spin { - to { - transform: rotate(360deg); - } + to { + transform: rotate(360deg); + } } .loading-subtitle { - font-size: 0.875rem; - color: var(--text-secondary); - margin-top: 0.5rem; + font-size: 0.875rem; + color: var(--text-secondary); + margin-top: 0.5rem; } diff --git a/service/config/config.go b/service/config/config.go index 13ef5ed..a2103f0 100644 --- a/service/config/config.go +++ b/service/config/config.go @@ -30,10 +30,6 @@ type Config struct { IMAPMaxReconnectDelay int IMAPMaxReconnectAttempts int - EndpointPrefixProton string - EndpointPrefixNtfy string - EndpointPrefixUP string - StoragePath string } @@ -53,7 +49,7 @@ func Load() (*Config, error) { ProtonIMAPPassword: os.Getenv("PROTON_IMAP_PASSWORD"), ProtonBridgeHost: getEnvString("PROTON_BRIDGE_HOST", "protonmail-bridge"), ProtonBridgePort: getEnvInt("PROTON_BRIDGE_PORT", 143), - ProtonPrismTopic: getEnvString("PROTON_PRISM_TOPIC", "Proton Mail"), + ProtonPrismTopic: "Proton Mail", IMAPInbox: "INBOX", IMAPSeenFlag: "\\Seen", @@ -61,10 +57,6 @@ func Load() (*Config, error) { IMAPMaxReconnectDelay: 300000, IMAPMaxReconnectAttempts: 50, - EndpointPrefixProton: "proton-", - EndpointPrefixNtfy: "ntfy-", - EndpointPrefixUP: "up-", - StoragePath: getEnvString("STORAGE_PATH", "./data/prism.db"), } diff --git a/service/notification/dispatcher.go b/service/notification/dispatcher.go index 91a4b06..78c3fd1 100644 --- a/service/notification/dispatcher.go +++ b/service/notification/dispatcher.go @@ -6,7 +6,6 @@ import ( "fmt" "log/slog" "net/http" - "strings" "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 { mapping, err := d.store.GetEndpointMapping(endpoint) if err != nil { + d.logger.Error("Failed to get endpoint mapping", "endpoint", endpoint, "error", err) return fmt.Errorf("failed to get mapping: %w", err) } if mapping == nil { - appName := strings.TrimPrefix(endpoint, "ntfy-") - appName = strings.TrimPrefix(appName, "proton-") + d.logger.Info("No mapping found for endpoint, creating new registration", "endpoint", endpoint) - 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) } mapping, err = d.store.GetEndpointMapping(endpoint) 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) } } @@ -51,6 +54,7 @@ func (d *Dispatcher) Send(endpoint string, notif Notification) error { case ChannelSignal: return d.sendSignal(mapping, notif) default: + d.logger.Error("Unknown channel type", "channel", 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 { groupID := mapping.GroupID if groupID == nil || *groupID == "" { + d.logger.Info("No group ID found, creating new group", "appName", mapping.AppName) + newGroupID, err := d.createGroup(mapping.AppName) if err != nil { + d.logger.Error("Failed to create group", "appName", mapping.AppName, "error", err) return fmt.Errorf("failed to create group: %w", err) } groupID = &newGroupID + d.logger.Info("Created new group", "appName", mapping.AppName, "groupID", *groupID) + if err := d.store.UpdateGroupID(mapping.Endpoint, *groupID); err != nil { d.logger.Warn("Failed to update group ID", "error", err) } } 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) } - d.logger.Debug("Sent Signal notification", "app", mapping.AppName, "message", notif.Message) return nil } @@ -113,15 +122,17 @@ func (d *Dispatcher) createGroup(appName string) (string, error) { func (d *Dispatcher) sendGroupMessage(groupID string, notif Notification) error { account, err := d.signalClient.GetLinkedAccount() if err != nil { + d.logger.Error("Failed to get linked account", "error", err) return fmt.Errorf("failed to get linked account: %w", err) } if account == nil { + d.logger.Error("No linked Signal account found") return fmt.Errorf("no linked Signal account") } message := notif.Message 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{}{ @@ -131,6 +142,9 @@ func (d *Dispatcher) sendGroupMessage(groupID string, notif Notification) error } _, err = d.signalClient.CallWithAccount("send", params, account.Number) + if err != nil { + d.logger.Error("Signal send API call failed", "error", err) + } return err } diff --git a/service/proton/connection.go b/service/proton/connection.go index 7374095..965e0aa 100644 --- a/service/proton/connection.go +++ b/service/proton/connection.go @@ -5,20 +5,33 @@ import ( "fmt" "time" - "github.com/emersion/go-imap/client" + "github.com/emersion/go-imap/v2/imapclient" ) func (m *Monitor) connect() error { 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 { return fmt.Errorf("failed to dial: %w", err) } - if err := c.Login(m.cfg.ProtonIMAPUsername, m.cfg.ProtonIMAPPassword); err != nil { - if logoutErr := c.Logout(); logoutErr != nil { - m.logger.Error("Logout failed", "error", logoutErr) - } + if err := c.Login(m.cfg.ProtonIMAPUsername, m.cfg.ProtonIMAPPassword).Wait(); err != nil { + c.Close() 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 { - _, err := m.client.Select(m.cfg.IMAPInbox, false) + selectCmd := m.client.Select(m.cfg.IMAPInbox, nil) + _, err := selectCmd.Wait() if err != nil { return fmt.Errorf("failed to select inbox: %w", err) } if m.monitorStartTime.IsZero() { 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) - m.client.Updates = updates - - 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() + idleCmd, err := m.client.Idle() + if err != nil { + return fmt.Errorf("failed to start idle: %w", err) + } for { select { case <-ctx.Done(): + idleCmd.Close() return ctx.Err() - case update := <-updates: - switch u := update.(type) { - case *client.MailboxUpdate: - if u.Mailbox.UnseenSeqNum > 0 { - if err := m.handleNewMessages(); err != nil { - m.logger.Error("Failed to handle new messages", "error", err) - } - } + case <-m.newMessagesChan: + idleCmd.Close() + + if err := m.sendNotification(); err != nil { + m.logger.Error("Failed to send notification", "error", err) } - case <-ticker.C: - close(stop) - <-idleErr - - if err := m.client.Noop(); err != nil { - return fmt.Errorf("noop failed: %w", err) + idleCmd, err = m.client.Idle() + if err != nil { + return fmt.Errorf("failed to restart idle: %w", err) } - - stop = make(chan struct{}) - go func() { - idleErr <- m.client.Idle(stop, nil) - }() - - case err := <-idleErr: - return fmt.Errorf("idle ended: %w", err) } } } diff --git a/service/proton/email.go b/service/proton/email.go index 363db47..abd09c3 100644 --- a/service/proton/email.go +++ b/service/proton/email.go @@ -5,74 +5,67 @@ import ( "prism/service/notification" - "github.com/emersion/go-imap" + "github.com/emersion/go-imap/v2" ) -func (m *Monitor) handleNewMessages() error { - criteria := imap.NewSearchCriteria() - criteria.WithoutFlags = []string{m.cfg.IMAPSeenFlag} - - uids, err := m.client.UidSearch(criteria) +func (m *Monitor) sendNotification() error { + selectData, err := m.client.Select(m.cfg.IMAPInbox, nil).Wait() 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 } - seqSet := new(imap.SeqSet) - seqSet.AddNum(uids...) + latestSeq := selectData.NumMessages - messages := make(chan *imap.Message, 10) - done := make(chan error, 1) + seqSet := imap.SeqSetNum(latestSeq) - go func() { - done <- m.client.UidFetch(seqSet, []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}, messages) - }() - - for msg := range messages { - if err := m.processMessage(msg); err != nil { - m.logger.Error("Failed to process message", "error", err) - } + fetchOptions := &imap.FetchOptions{ + Envelope: true, + UID: true, } - if err := <-done; err != nil { - return fmt.Errorf("fetch failed: %w", err) - } + fetchCmd := m.client.Fetch(seqSet, fetchOptions) + defer fetchCmd.Close() - return nil -} - -func (m *Monitor) processMessage(msg *imap.Message) error { - if msg.Envelope == nil { + msg := fetchCmd.Next() + if msg == nil { + m.logger.Warn("No message found to send notification") return nil } - if msg.Envelope.Date.Before(m.monitorStartTime) { - m.logger.Debug("Skipping old email", "date", msg.Envelope.Date, "subject", msg.Envelope.Subject) + msgData, err := msg.Collect() + 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 } var from string - if len(msg.Envelope.From) > 0 { - addr := msg.Envelope.From[0] - if addr.PersonalName != "" { - from = addr.PersonalName + if len(msgData.Envelope.From) > 0 { + addr := msgData.Envelope.From[0] + if addr.Name != "" { + from = addr.Name } else { - from = addr.Address() + from = addr.Addr() } } else { from = "Unknown sender" } - subject := msg.Envelope.Subject + subject := msgData.Envelope.Subject if subject == "" { subject = "No subject" } - endpoint := m.cfg.EndpointPrefixProton + m.cfg.ProtonPrismTopic - notif := notification.Notification{ Title: from, Message: subject, @@ -82,18 +75,18 @@ func (m *Monitor) processMessage(msg *imap.Message) error { Endpoint: "/api/proton-mail/mark-read", Method: "POST", Data: map[string]interface{}{ - "uid": msg.Uid, + "uid": msgData.UID, }, }, }, } - if err := m.dispatcher.Send(endpoint, notif); err != nil { - m.logger.Error("Failed to send notification", "endpoint", endpoint, "error", err) + if err := m.dispatcher.Send(m.cfg.ProtonPrismTopic, notif); err != nil { + m.logger.Error("Failed to send notification", "error", 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 } @@ -102,11 +95,28 @@ func (m *Monitor) MarkAsRead(uid uint32) error { return fmt.Errorf("not connected") } - seqSet := new(imap.SeqSet) - seqSet.AddNum(uid) + uidSet := imap.UIDSet{} + uidSet.AddNum(imap.UID(uid)) - item := imap.FormatFlagsOp(imap.AddFlags, true) - flags := []interface{}{m.cfg.IMAPSeenFlag} + storeFlags := &imap.StoreFlags{ + 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 } diff --git a/service/proton/monitor.go b/service/proton/monitor.go index 9a8507e..af060e8 100644 --- a/service/proton/monitor.go +++ b/service/proton/monitor.go @@ -8,22 +8,24 @@ import ( "prism/service/config" "prism/service/notification" - "github.com/emersion/go-imap/client" + "github.com/emersion/go-imap/v2/imapclient" ) type Monitor struct { cfg *config.Config dispatcher *notification.Dispatcher logger *slog.Logger - client *client.Client + client *imapclient.Client monitorStartTime time.Time + newMessagesChan chan struct{} } func NewMonitor(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger) *Monitor { return &Monitor{ - cfg: cfg, - dispatcher: dispatcher, - logger: logger, + cfg: cfg, + dispatcher: dispatcher, + logger: logger, + newMessagesChan: make(chan struct{}, 10), } } diff --git a/service/server/handlers_fragment.go b/service/server/handlers_fragment.go index be135f0..56183a4 100644 --- a/service/server/handlers_fragment.go +++ b/service/server/handlers_fragment.go @@ -34,12 +34,14 @@ func (s *Server) handleFragmentHealth(w http.ResponseWriter, r *http.Request) { if hasProton { protonStatus := "Disconnected" protonClass := "status-error" + protonTooltip := "" if s.protonMonitor.IsConnected() { protonStatus = "Connected" protonClass = "status-ok" + protonTooltip = fmt.Sprintf(`%s`, s.cfg.ProtonIMAPUsername) } html += fmt.Sprintf(` -
Proton Mail: %s
`, protonClass, protonStatus) +
Proton Mail: %s%s
`, protonClass, protonStatus, protonTooltip) } html += ` @@ -116,27 +118,29 @@ func (s *Server) handleFragmentEndpoints(w http.ResponseWriter, r *http.Request) isSignal := m.Channel == notification.ChannelSignal isWebhook := m.Channel == notification.ChannelWebhook channelBadge := "Signal" + channelTooltip := "" if isWebhook { channelBadge = "Webhook" } + if isSignal && m.GroupID != nil { + channelTooltip = fmt.Sprintf(`Group ID: %s`, *m.GroupID) + } + if isWebhook && m.UpEndpoint != nil { + channelTooltip = fmt.Sprintf(`%s`, *m.UpEndpoint) + } html := fmt.Sprintf(`
  • %s
    - %s`, m.AppName, m.Channel, channelBadge) + %s%s`, m.AppName, m.Channel, channelBadge, channelTooltip) if m.UpEndpoint != nil && isWebhook { - // Extract hostname from webhook URL if u, err := url.Parse(*m.UpEndpoint); err == nil { html += fmt.Sprintf(`%s`, u.Hostname()) } } - if m.GroupID != nil && isSignal { - html += fmt.Sprintf(`Groud ID: %s`, *m.GroupID) - } - html += `
    ` if m.UpEndpoint != nil { diff --git a/service/server/handlers_ntfy.go b/service/server/handlers_ntfy.go index bd05a96..9649558 100644 --- a/service/server/handlers_ntfy.go +++ b/service/server/handlers_ntfy.go @@ -63,15 +63,13 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) { title = "" } - fullEndpoint := s.cfg.EndpointPrefixNtfy + topic - notif := notification.Notification{ Title: title, Message: message, } - if err := s.dispatcher.Send(fullEndpoint, notif); err != nil { - s.logger.Error("Failed to send notification", "endpoint", fullEndpoint, "error", err) + if err := s.dispatcher.Send(topic, notif); err != nil { + s.logger.Error("Failed to send notification", "endpoint", topic, "error", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } diff --git a/service/server/handlers_webhook.go b/service/server/handlers_webhook.go index 5f953c5..a241118 100644 --- a/service/server/handlers_webhook.go +++ b/service/server/handlers_webhook.go @@ -34,9 +34,7 @@ func (s *Server) handleWebhookRegister(w http.ResponseWriter, r *http.Request) { return } - endpoint := s.cfg.EndpointPrefixUP + req.AppName - - if err := s.store.Register(endpoint, req.AppName, notification.ChannelWebhook, nil, &req.UpEndpoint); err != nil { + if err := s.store.Register(req.AppName, req.AppName, notification.ChannelWebhook, nil, &req.UpEndpoint); err != nil { s.logger.Error("Failed to register webhook endpoint", "error", err) w.Header().Set("Content-Type", "application/json") 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") response := map[string]string{ - "endpoint": endpoint, + "endpoint": req.AppName, "appName": req.AppName, "channel": "webhook", } diff --git a/service/util/format.go b/service/util/format.go index 83a8356..8c9ac2d 100644 --- a/service/util/format.go +++ b/service/util/format.go @@ -7,16 +7,46 @@ import ( ) func FormatPhoneNumber(number string) string { - cleaned := strings.ReplaceAll(number, " ", "") - cleaned = strings.ReplaceAll(cleaned, "-", "") - cleaned = strings.ReplaceAll(cleaned, "(", "") - cleaned = strings.ReplaceAll(cleaned, ")", "") - - if !strings.HasPrefix(cleaned, "+") { - cleaned = "+" + cleaned + if number == "" { + return number } - 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 {