mirror of
https://github.com/lone-cloud/prism
synced 2026-06-03 08:43:10 -07:00
make protonmail notifications work again, cleaner UI, update to v2 of IMAP lib,
This commit is contained in:
parent
051a13cb7a
commit
e31ccc76c5
19 changed files with 438 additions and 366 deletions
|
|
@ -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
|
||||
# PROTON_BRIDGE_PORT=143
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
7
Makefile
7
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"; \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
6
go.mod
6
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
|
||||
)
|
||||
|
|
|
|||
44
go.sum
44
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=
|
||||
|
|
|
|||
424
public/index.css
424
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(`<span class="tooltip">%s</span>`, s.cfg.ProtonIMAPUsername)
|
||||
}
|
||||
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>
|
||||
|
|
@ -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(`<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">
|
||||
<div class="endpoint-info">
|
||||
<div class="endpoint-name"><strong>%s</strong></div>
|
||||
<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 {
|
||||
// Extract hostname from webhook URL
|
||||
if u, err := url.Parse(*m.UpEndpoint); err == nil {
|
||||
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">`
|
||||
|
||||
if m.UpEndpoint != nil {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue