From 0167ec1f19dc5256d097331de9a57b26f642a19e Mon Sep 17 00:00:00 2001 From: Jaehyun Nam Date: Mon, 14 Sep 2026 06:44:49 +0000 Subject: [PATCH] Keep member mail out of Spam, and let sync survive a vanished message Mail redistributed by a Google Group loses SPF and DKIM alignment on the second hop, so legitimate mail to a member address was classified as spam -- and marking it "not spam" in Gmail did not stick, because the next message arrived through the same path and was judged the same way. Provisioning now creates a Gmail filter per member address matching list:., the list id the group stamps on every redistribution, with SPAM removed. Existing members are exempted once per process on the first sync tick, so an upgrade does not require re-approving anyone. Separately, messages.get returning 404 -- a message deleted or swept between the history record and the fetch -- failed the whole batch and left the sync cursor stuck on it, stopping delivery entirely. Skip those. --- src/google/provisioning.ts | 35 +++++++++++++++++++++++++++++++++++ src/google/sync.ts | 17 +++++++++++++---- src/web/server.ts | 24 ++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/google/provisioning.ts b/src/google/provisioning.ts index 935e327..e3dd916 100644 --- a/src/google/provisioning.ts +++ b/src/google/provisioning.ts @@ -54,6 +54,34 @@ export async function createGroupAlias(aliasEmail: string, displayName: string): } } +/** + * Stop Gmail judging our own redistribution as spam. + * + * A member address is a Group, so mail reaches the mailbox on a second hop. + * DMARC cannot align on a forwarded message, and Gmail files legitimate mail + * as spam on that basis -- even though it authenticated on arrival and Google + * preserved the proof in the ARC chain it wrote itself. + * + * Scoped to the Group's own List-Id, so this exempts exactly the hop labMail + * created and nothing else. Google still filters the message when it first + * arrives for the Group; what this drops is the second judgement, the one made + * after the alignment it needs has been lost. + */ +export async function ensureNeverSpam(aliasEmail: string): Promise { + const [local, domain] = aliasEmail.trim().toLowerCase().split('@') + if (!local || !domain) return + const query = `list:${local}.${domain}` + + const api = gmail() + const existing = await api.users.settings.filters.list({ userId: 'me' }) + if ((existing.data.filter ?? []).some((f) => f.criteria?.query === query)) return + + await api.users.settings.filters.create({ + userId: 'me', + requestBody: { criteria: { query }, action: { removeLabelIds: ['SPAM'] } }, + }) +} + export interface SendAsResult { verified: boolean /** Why the address cannot send yet, when it cannot. */ @@ -85,6 +113,13 @@ export async function provisionMember(aliasEmail: string, displayName: string): // here is still fatal to it. await createGroupAlias(aliasEmail, displayName) + // Not fatal: the address works without it, it just collects false spam. + try { + await ensureNeverSpam(aliasEmail) + } catch (err) { + console.error(`[provision] spam exemption for ${aliasEmail}:`, (err as Error).message) + } + // Sending is a separate question, and not one LabMail can settle: the // send-as entry has to be added by hand. Report it as missing unless it is // already there, and let the sync tick notice when it appears. diff --git a/src/google/sync.ts b/src/google/sync.ts index a7ef114..fff9453 100644 --- a/src/google/sync.ts +++ b/src/google/sync.ts @@ -221,10 +221,19 @@ async function fetchAndStore(ids: string[], ctx: ReturnType { - const res = await withRetry( - () => api.users.messages.get({ userId: 'me', id, format: 'full' }), - `messages.get ${id}`, - ) + let res + try { + res = await withRetry( + () => api.users.messages.get({ userId: 'me', id, format: 'full' }), + `messages.get ${id}`, + ) + } catch (err) { + // Gone between the history record and this fetch -- deleted in Gmail, or + // swept from Spam. There is nothing to store, and failing the batch would + // leave the cursor stuck on it and stop mail arriving at all. + if ((err as { code?: number })?.code === 404) return + throw err + } const parsed = parseMessage(res.data) parsed.labels = await rescueConfirmation(parsed) storeMessage(parsed, ctx) diff --git a/src/web/server.ts b/src/web/server.ts index a2abed1..5fe4f3f 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -51,6 +51,29 @@ async function bootstrapAdmin(): Promise { console.log(`[boot] created admin account "${config.admin.username}"`) } +/** + * Give every existing address the spam exemption new ones get at approval. + * + * Once per process rather than per tick: it is a couple of API calls, and an + * address that gains the exemption keeps it. + */ +let exempted = false +async function exemptMembersOnce(): Promise { + if (exempted) return + exempted = true + const rows = db.prepare( + `SELECT alias_email FROM users WHERE status = 'active' AND alias_email IS NOT NULL`, + ).all() as { alias_email: string }[] + try { + const { ensureNeverSpam } = await import('../google/provisioning.ts') + for (const row of rows) await ensureNeverSpam(row.alias_email) + } catch (err) { + // Retried on the next start; nothing else depends on it. + exempted = false + console.error('[sync] spam exemptions:', (err as Error).message) + } +} + /** * Periodic sync, in-process so a deployment stays one container. * No-op until Google is connected; never overlaps itself. @@ -66,6 +89,7 @@ async function syncTick(): Promise { await linkDrafts() // Send-as entries change outside LabMail; one request per tick notices. await reconcileSendAs() + await exemptMembersOnce() if (result.changed > 0 || assigned > 0) { console.log(`[sync] ${result.changed} changed, ${assigned} newly attributed`) }