From 3d1a89cc8f0848691b6388ff785de2a243554ee8 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Mon, 25 May 2026 00:17:37 +0200 Subject: [PATCH] fix email and bsky --- apps/relay/src/delivery/bluesky-dm.ts | 91 ++++++++++++++++-------- apps/relay/src/delivery/dispatcher.ts | 2 +- apps/relay/src/delivery/email.ts | 7 +- apps/relay/src/env.ts | 8 +-- apps/relay/src/identity/resolve.ts | 24 +++++++ apps/relay/test/email.test.ts | 9 ++- apps/relay/wrangler.toml | 12 ++-- apps/web/src/lib/server/featureAccess.ts | 3 +- 8 files changed, 112 insertions(+), 44 deletions(-) diff --git a/apps/relay/src/delivery/bluesky-dm.ts b/apps/relay/src/delivery/bluesky-dm.ts index d4227a9..967a04b 100644 --- a/apps/relay/src/delivery/bluesky-dm.ts +++ b/apps/relay/src/delivery/bluesky-dm.ts @@ -2,11 +2,20 @@ // password) through the chat service, proxied via the bot's PDS: // createSession → chat.bsky.convo.getConvoForMembers → chat.bsky.convo.sendMessage // -// `createSession` is heavily rate-limited (a few per day), so the session is -// cached in KV and routine access-token expiry is handled with `refreshSession` -// (not rate-limited). createSession is only hit on the first send or when the -// refresh token itself is dead. +// The bot's PDS is resolved at runtime from its identity (handle or DID) and used +// for BOTH login and the proxied chat calls — the bsky.social entryway returns +// 501 for proxied chat, so calls must hit the account's real PDS host. Resolution +// reuses the KV-cached DID-document resolver, and the resolved PDS is cached in +// the session, so there's no hardcoded service endpoint to configure. +// +// `createSession` is heavily rate-limited (a few per day), so the session (tokens +// + resolved PDS) is cached in KV, and routine access-token expiry is handled with +// `refreshSession` (not rate-limited). createSession is only hit on the first send +// or when the refresh token itself is dead. +import type { ActorIdentifier } from '@atcute/lexicons/syntax'; + import type { Env } from '../env'; +import { makeActorResolver } from '../identity/resolve'; const CHAT_PROXY = 'did:web:api.bsky.chat#bsky_chat'; const SESSION_KEY = 'bsky-dm:session'; @@ -25,12 +34,18 @@ export class BlueskyDMError extends Error { interface Session { accessJwt: string; refreshJwt: string; + /** The bot's resolved PDS endpoint — login + chat calls go here. */ + pds: string; } -function service(env: Env): string { - return env.BLUESKY_DM_SERVICE && env.BLUESKY_DM_SERVICE.length > 0 - ? env.BLUESKY_DM_SERVICE - : 'https://bsky.social'; +/** Resolve the bot's PDS from BLUESKY_DM_IDENTIFIER (handle or DID). The resolver + * returns `new URL(pds).href`, which has a trailing slash — strip it so + * `${pds}/xrpc/...` doesn't produce a `//xrpc` (404) path. */ +async function resolveBotPds(env: Env): Promise { + const { pds } = await makeActorResolver(env.CACHE).resolve( + env.BLUESKY_DM_IDENTIFIER as ActorIdentifier, + ); + return pds.replace(/\/+$/, ''); } function loadSession(env: Env): Promise { @@ -42,9 +57,11 @@ async function saveSession(env: Env, s: Session): Promise { return s; } -/** App-password login. Rate-limited — only on first send or a dead refresh token. */ +/** App-password login at the bot's resolved PDS. Rate-limited — only on first + * send or a dead refresh token. */ async function createSession(env: Env): Promise { - const res = await fetch(`${service(env)}/xrpc/com.atproto.server.createSession`, { + const pds = await resolveBotPds(env); + const res = await fetch(`${pds}/xrpc/com.atproto.server.createSession`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ @@ -53,28 +70,29 @@ async function createSession(env: Env): Promise { }), }); if (!res.ok) throw new BlueskyDMError(res.status, 'createSession'); - const { accessJwt, refreshJwt } = (await res.json()) as Session; - return saveSession(env, { accessJwt, refreshJwt }); + const { accessJwt, refreshJwt } = (await res.json()) as Pick; + return saveSession(env, { accessJwt, refreshJwt, pds }); } -/** Exchange the refresh token for a fresh session. Not createSession-limited. */ -async function refreshSession(env: Env, refreshJwt: string): Promise { - const res = await fetch(`${service(env)}/xrpc/com.atproto.server.refreshSession`, { +/** Exchange the refresh token for a fresh session (at the cached PDS). Not + * createSession-rate-limited. */ +async function refreshSession(env: Env, session: Session): Promise { + const res = await fetch(`${session.pds}/xrpc/com.atproto.server.refreshSession`, { method: 'POST', - headers: { authorization: `Bearer ${refreshJwt}` }, + headers: { authorization: `Bearer ${session.refreshJwt}` }, }); if (!res.ok) throw new BlueskyDMError(res.status, 'refreshSession'); - const { accessJwt, refreshJwt: rotated } = (await res.json()) as Session; - return saveSession(env, { accessJwt, refreshJwt: rotated }); + const { accessJwt, refreshJwt } = (await res.json()) as Pick; + return saveSession(env, { accessJwt, refreshJwt, pds: session.pds }); } async function chatCall( - env: Env, + pds: string, jwt: string, method: string, init: { query?: Record; body?: unknown }, ): Promise { - const url = new URL(`${service(env)}/xrpc/${method}`); + const url = new URL(`${pds}/xrpc/${method}`); for (const [k, v] of Object.entries(init.query ?? {})) url.searchParams.set(k, v); const res = await fetch(url, { method: init.body !== undefined ? 'POST' : 'GET', @@ -85,18 +103,23 @@ async function chatCall( }, body: init.body !== undefined ? JSON.stringify(init.body) : undefined, }); - if (!res.ok) throw new BlueskyDMError(res.status, method); + if (!res.ok) { + // Include the chat service's error body (e.g. `{error,message}`) — the status + // alone doesn't say why (recipient settings, token scope, …). + const body = await res.text().catch(() => ''); + throw new BlueskyDMError(res.status, `${method}${body ? ` ${body}` : ''}`); + } return (await res.json()) as T; } -async function deliver(env: Env, jwt: string, recipientDid: string, text: string): Promise { +async function deliver(session: Session, recipientDid: string, text: string): Promise { const { convo } = await chatCall<{ convo: { id: string } }>( - env, - jwt, + session.pds, + session.accessJwt, 'chat.bsky.convo.getConvoForMembers', { query: { members: recipientDid } }, ); - await chatCall(env, jwt, 'chat.bsky.convo.sendMessage', { + await chatCall(session.pds, session.accessJwt, 'chat.bsky.convo.sendMessage', { body: { convoId: convo.id, message: { text } }, }); } @@ -104,19 +127,27 @@ async function deliver(env: Env, jwt: string, recipientDid: string, text: string /** * Send a DM from the bot account to `recipientDid`. Throws {@link BlueskyDMError}. * On a 401 (expired access token) the session is refreshed (or, if the refresh - * token is dead, re-created) and the send retried once. + * token is dead, re-created) and the send retried once. A cached session missing + * the resolved PDS (pre-upgrade) forces a re-login. */ export async function sendBlueskyDM(env: Env, recipientDid: string, text: string): Promise { - let session = (await loadSession(env)) ?? (await createSession(env)); + let session = await loadSession(env); + // Migrate a pre-upgrade cached session (valid tokens, no resolved PDS) WITHOUT + // spending a rate-limited createSession — just attach the PDS and reuse the + // tokens. An expired access token then refreshes (not rate-limited) below. + if (session && !session.pds) { + session = await saveSession(env, { ...session, pds: await resolveBotPds(env) }); + } + if (!session) session = await createSession(env); try { - await deliver(env, session.accessJwt, recipientDid, text); + await deliver(session, recipientDid, text); } catch (err) { if (!(err instanceof BlueskyDMError) || err.statusCode !== 401) throw err; try { - session = await refreshSession(env, session.refreshJwt); + session = await refreshSession(env, session); } catch { session = await createSession(env); } - await deliver(env, session.accessJwt, recipientDid, text); + await deliver(session, recipientDid, text); } } diff --git a/apps/relay/src/delivery/dispatcher.ts b/apps/relay/src/delivery/dispatcher.ts index 5f848da..c283db4 100644 --- a/apps/relay/src/delivery/dispatcher.ts +++ b/apps/relay/src/delivery/dispatcher.ts @@ -86,7 +86,7 @@ async function reapIfDead(env: Env, job: DispatchJob, err: unknown): Promise"); unwrap it if present. */ +export function bareAddress(from: string): string { + return (/<([^>]+)>/.exec(from)?.[1] ?? from).trim(); +} + /** * Send one email via comail. Resolves with the comail message id, or throws * {@link EmailError} on a non-2xx response or a rejected recipient. @@ -50,7 +55,7 @@ export async function sendEmail(env: Env, msg: EmailMessage): Promise<{ messageI 'content-type': 'application/json', }, body: JSON.stringify({ - from: env.COMAIL_FROM, + from: bareAddress(env.COMAIL_FROM), to: msg.to, subject: msg.subject, text: msg.text, diff --git a/apps/relay/src/env.ts b/apps/relay/src/env.ts index a8246ea..7cad624 100644 --- a/apps/relay/src/env.ts +++ b/apps/relay/src/env.ts @@ -115,7 +115,8 @@ export interface Env { COMAIL_API_KEY: string; /** Account DID for the `X-Atmos-DID` header (var). */ COMAIL_DID: string; - /** Enrolled sender address for the `from` field, e.g. "atmo.pub " (var). */ + /** Enrolled sender address for the `from` field — a bare address, e.g. + * "notify@atmo.pub" (comail rejects "Name "; the relay strips it anyway) (var). */ COMAIL_FROM: string; /** Max emails delivered to ONE recipient per rolling day (var; default 10). See delivery/limits.ts. */ EMAIL_DAILY_PER_RECIPIENT?: string; @@ -123,12 +124,11 @@ export interface Env { EMAIL_DAILY_GLOBAL?: string; // Bluesky DM delivery — the relay sends DMs from a configured bot account. - /** Bot handle or DID used for createSession (var). */ + /** Bot handle or DID (var). The bot's PDS is resolved from this at runtime for + * both login and chat — no service endpoint to configure. */ BLUESKY_DM_IDENTIFIER: string; /** Bot app password (secret). */ BLUESKY_DM_APP_PASSWORD: string; - /** Bot PDS service URL; defaults to https://bsky.social (var). */ - BLUESKY_DM_SERVICE?: string; } /** diff --git a/apps/relay/src/identity/resolve.ts b/apps/relay/src/identity/resolve.ts index 89c85f1..d925173 100644 --- a/apps/relay/src/identity/resolve.ts +++ b/apps/relay/src/identity/resolve.ts @@ -1,9 +1,14 @@ import { + type ActorResolver, CompositeDidDocumentResolver, + CompositeHandleResolver, type DidDocumentResolver, + DohJsonHandleResolver, + LocalActorResolver, PlcDidDocumentResolver, type ResolveDidDocumentOptions, WebDidDocumentResolver, + WellKnownHandleResolver, } from '@atcute/identity-resolver'; import type { Did } from '@atcute/lexicons'; @@ -55,6 +60,25 @@ export function makeResolver(cache: KVNamespace): DidDocumentResolver { return new CachedDidDocumentResolver(composite, cache); } +const DOH_URL = 'https://cloudflare-dns.com/dns-query'; + +/** + * Resolve an actor (handle OR DID) to `{ did, handle, pds }`, reusing the + * KV-cached DID-document resolver (so the PDS lookup is cached ~5min). Used to + * find the DM bot's PDS at runtime — no hardcoded service endpoint needed. + */ +export function makeActorResolver(cache: KVNamespace): ActorResolver { + return new LocalActorResolver({ + handleResolver: new CompositeHandleResolver({ + methods: { + http: new WellKnownHandleResolver(), + dns: new DohJsonHandleResolver({ dohUrl: DOH_URL }), + }, + }), + didDocumentResolver: makeResolver(cache), + }); +} + /** * Best-effort handle for a DID, read from the DID document's `alsoKnownAs` * (`at://`). For display only (the claimed handle is not bidirectionally diff --git a/apps/relay/test/email.test.ts b/apps/relay/test/email.test.ts index ff9dec4..fb12bad 100644 --- a/apps/relay/test/email.test.ts +++ b/apps/relay/test/email.test.ts @@ -1,7 +1,7 @@ import { env } from 'cloudflare:test'; import { beforeEach, expect, it } from 'vitest'; -import { EmailError, sendEmail } from '../src/delivery/email'; +import { bareAddress, EmailError, sendEmail } from '../src/delivery/email'; import { installFetchMock, mockComailError, mockComailOk, mockComailRejected } from './helpers'; @@ -28,3 +28,10 @@ it('throws EmailError when the recipient is rejected (2xx, empty accepted)', asy mockComailRejected(); await expect(sendEmail(env, msg)).rejects.toBeInstanceOf(EmailError); }); + +it('bareAddress strips a display name (comail requires a bare from)', () => { + expect(bareAddress('atmo.pub ')).toBe('notify@atmo.pub'); + expect(bareAddress(' Name ')).toBe('a@b.com'); + expect(bareAddress('notify@atmo.pub')).toBe('notify@atmo.pub'); + expect(bareAddress(' notify@atmo.pub ')).toBe('notify@atmo.pub'); +}); diff --git a/apps/relay/wrangler.toml b/apps/relay/wrangler.toml index d2d07be..9cc3122 100644 --- a/apps/relay/wrangler.toml +++ b/apps/relay/wrangler.toml @@ -43,18 +43,18 @@ BOT_USERNAME = "atmo_notify_bot" # e.g. "atmonotifsbot" VAPID_PUBLIC_KEY = "BF4pVUiFeh9wltn6Rj151RHA4WfidcRRv8kXp2aKcRATi_2gUgq0uGX8jVY1EczXqOvRtluqIxRj6Mtf5d1ImRw" VAPID_SUBJECT = "https://atmo.pub" # Email via comail (https://comail.at). COMAIL_DID = the account DID for the -# X-Atmos-DID header; COMAIL_FROM = an enrolled sender address. +# X-Atmos-DID header; COMAIL_FROM = an enrolled sender address — a BARE address, +# no "Name " display name (comail rejects that; the relay strips it anyway). COMAIL_DID = "did:plc:jf3acz4zktzkaptswh5so43u" -COMAIL_FROM = "atmo.pub " +COMAIL_FROM = "notify@atmo.pub" # Daily email caps (rolling 24h). Per-recipient = anti-spam/cost; global = keep # under the comail plan. Omit to use the defaults (10 / 100). See delivery/limits.ts. EMAIL_DAILY_PER_RECIPIENT = "10" EMAIL_DAILY_GLOBAL = "10" -# Bluesky DM via a bot account. BLUESKY_DM_IDENTIFIER = the bot's handle or DID -# (used for createSession); BLUESKY_DM_SERVICE = its PDS (defaults to -# https://bsky.social if omitted). The app password is the BLUESKY_DM_APP_PASSWORD secret. +# Bluesky DM via a bot account. BLUESKY_DM_IDENTIFIER = the bot's handle or DID; +# its PDS is resolved at runtime (login + chat both use it), so there's no service +# endpoint to set. The app password is the BLUESKY_DM_APP_PASSWORD secret. BLUESKY_DM_IDENTIFIER = "did:plc:jf3acz4zktzkaptswh5so43u" -BLUESKY_DM_SERVICE = "https://bsky.social" # Secrets set via `wrangler secret put`: # - TELEGRAM_BOT_TOKEN diff --git a/apps/web/src/lib/server/featureAccess.ts b/apps/web/src/lib/server/featureAccess.ts index f35f2fb..2a5fff2 100644 --- a/apps/web/src/lib/server/featureAccess.ts +++ b/apps/web/src/lib/server/featureAccess.ts @@ -3,7 +3,8 @@ // link an address (the `linkEmail` command rejects them too). Add DIDs here to // grant access. Kept server-side so the list never ships to the browser. const EMAIL_WHITELIST = new Set([ - // 'did:plc:257wekqxg4hyapkq6k47igmp', + 'did:plc:257wekqxg4hyapkq6k47igmp', + 'did:plc:dy67wyyakm7u4v2lthy5zwbn' ]); /** True if `did` may use the email delivery channel. */ -- 2.51.2