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`) }