Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/google/provisioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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. */
Expand Down Expand Up @@ -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.
Expand Down
17 changes: 13 additions & 4 deletions src/google/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,10 +221,19 @@ async function fetchAndStore(ids: string[], ctx: ReturnType<typeof ownershipCont
const api = gmail()
let stored = 0
await mapLimit(ids, 8, async (id) => {
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)
Expand Down
24 changes: 24 additions & 0 deletions src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,29 @@ async function bootstrapAdmin(): Promise<void> {
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<void> {
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.
Expand All @@ -66,6 +89,7 @@ async function syncTick(): Promise<void> {
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`)
}
Expand Down