mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
chore: add Android release CI and sync local work
CI: - .github/workflows/release-android.yml builds APK on release publish and via workflow_dispatch, attaches it to the release. - android/app/build.gradle: optional release signingConfig driven by Gradle properties (ANDROID_KEYSTORE_BASE64 secret etc.); falls back to the debug keystore when no secrets are set. - .gitignore: untrack /android so the runner can build directly; ignore build outputs (build/, .cxx/), Metro/Expo dev logs, scratch. Track android/ sources including BulwarkFcmPackage.kt and google-services.json (Firebase Android keys are public per Google's docs and intended to ship in the APK). JS sync: push notifications + background task, vacation responder (api + store), contact activity component, search utilities, settings polish, calendar/contacts/email store and screen tweaks. Tooling: scripts/poll-reload.mjs — Windows polling-watcher that hits Metro /reload on file change (workaround for unreliable fs.watch without Watchman).
This commit is contained in:
+20
-1
@@ -1,6 +1,7 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { Email, EmailAddress, Mailbox, Thread } from './types';
|
||||
import { toWildcardQuery } from '../lib/search-utils';
|
||||
|
||||
const EMAIL_LIST_PROPERTIES = [
|
||||
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
|
||||
@@ -131,7 +132,7 @@ export async function searchEmails(
|
||||
limit = 30,
|
||||
): Promise<string[]> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const filter: Record<string, unknown> = { text: query };
|
||||
const filter: Record<string, unknown> = { text: toWildcardQuery(query) };
|
||||
if (mailboxId) filter.inMailbox = mailboxId;
|
||||
|
||||
const res = await jmapClient.request([
|
||||
@@ -145,6 +146,24 @@ export async function searchEmails(
|
||||
return res.methodResponses[0][1].ids;
|
||||
}
|
||||
|
||||
// Cross-mailbox query with an arbitrary JMAP filter (used by contact activity
|
||||
// to search "from OR to <addresses>" without picking a specific mailbox).
|
||||
export async function queryEmailsByFilter(
|
||||
filter: Record<string, unknown>,
|
||||
limit = 5,
|
||||
): Promise<string[]> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Email/query', {
|
||||
accountId,
|
||||
filter,
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
limit,
|
||||
}, '0'],
|
||||
]);
|
||||
return res.methodResponses[0][1].ids;
|
||||
}
|
||||
|
||||
export async function sendEmail(
|
||||
email: {
|
||||
from: EmailAddress[];
|
||||
|
||||
+84
-1
@@ -1,9 +1,92 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { StateChange } from './types';
|
||||
import type { PushSubscription, StateChange } from './types';
|
||||
|
||||
export type StateChangeHandler = (change: StateChange) => void;
|
||||
|
||||
// ─── PushSubscription (RFC 8620 §7.2) ───────────────────
|
||||
|
||||
export async function listPushSubscriptions(): Promise<PushSubscription[]> {
|
||||
const res = await jmapClient.request(
|
||||
[['PushSubscription/get', { ids: null }, '0']],
|
||||
[CAPABILITIES.CORE],
|
||||
);
|
||||
const [, body] = res.methodResponses[0] ?? [];
|
||||
return (body?.list as PushSubscription[]) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PushSubscription pointing the JMAP server at the given relay URL.
|
||||
* Returns the server-assigned id (which the client also registers with the
|
||||
* relay so the relay can route incoming pushes to an Expo token).
|
||||
*/
|
||||
export async function createPushSubscription(params: {
|
||||
deviceClientId: string;
|
||||
url: string;
|
||||
types: string[];
|
||||
}): Promise<string> {
|
||||
const res = await jmapClient.request(
|
||||
[
|
||||
[
|
||||
'PushSubscription/set',
|
||||
{
|
||||
create: {
|
||||
new: {
|
||||
deviceClientId: params.deviceClientId,
|
||||
url: params.url,
|
||||
types: params.types,
|
||||
},
|
||||
},
|
||||
},
|
||||
'0',
|
||||
],
|
||||
],
|
||||
[CAPABILITIES.CORE],
|
||||
);
|
||||
const [, body] = res.methodResponses[0] ?? [];
|
||||
const created = body?.created?.new as { id?: string } | undefined;
|
||||
if (!created?.id) {
|
||||
const notCreated = body?.notCreated?.new;
|
||||
throw new Error(
|
||||
`PushSubscription/set create failed: ${JSON.stringify(notCreated ?? body)}`,
|
||||
);
|
||||
}
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the verification code back to the server — the call that flips the
|
||||
* subscription from pending to active (RFC 8620 §7.2.2).
|
||||
*/
|
||||
export async function verifyPushSubscription(
|
||||
id: string,
|
||||
verificationCode: string,
|
||||
): Promise<void> {
|
||||
const res = await jmapClient.request(
|
||||
[
|
||||
[
|
||||
'PushSubscription/set',
|
||||
{ update: { [id]: { verificationCode } } },
|
||||
'0',
|
||||
],
|
||||
],
|
||||
[CAPABILITIES.CORE],
|
||||
);
|
||||
const [, body] = res.methodResponses[0] ?? [];
|
||||
if (body?.notUpdated?.[id]) {
|
||||
throw new Error(
|
||||
`PushSubscription verification failed: ${JSON.stringify(body.notUpdated[id])}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function destroyPushSubscription(id: string): Promise<void> {
|
||||
await jmapClient.request(
|
||||
[['PushSubscription/set', { destroy: [id] }, '0']],
|
||||
[CAPABILITIES.CORE],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the JMAP EventSource (SSE) endpoint for real-time push updates.
|
||||
* Returns a cleanup function to close the connection.
|
||||
|
||||
@@ -418,6 +418,18 @@ export interface StateChange {
|
||||
changed: Record<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
// RFC 8620 §7.2. A PushSubscription is session-scoped (not per-account) and
|
||||
// tells the server where to deliver StateChange events for the listed types.
|
||||
export interface PushSubscription {
|
||||
id: string;
|
||||
deviceClientId: string;
|
||||
url: string;
|
||||
keys?: { p256dh: string; auth: string } | null;
|
||||
verificationCode?: string | null;
|
||||
expires?: string | null;
|
||||
types?: string[] | null;
|
||||
}
|
||||
|
||||
// ─── Capability URNs ────────────────────────────────────
|
||||
|
||||
export const CAPABILITIES = {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
|
||||
export interface VacationResponse {
|
||||
id: string;
|
||||
isEnabled: boolean;
|
||||
fromDate: string | null;
|
||||
toDate: string | null;
|
||||
subject: string;
|
||||
textBody: string;
|
||||
htmlBody: string | null;
|
||||
}
|
||||
|
||||
const VACATION_USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.VACATION];
|
||||
|
||||
const DEFAULT: VacationResponse = {
|
||||
id: 'singleton',
|
||||
isEnabled: false,
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
subject: '',
|
||||
textBody: '',
|
||||
htmlBody: null,
|
||||
};
|
||||
|
||||
export async function getVacationResponse(): Promise<VacationResponse> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['VacationResponse/get', { accountId, ids: ['singleton'] }, '0']],
|
||||
VACATION_USING,
|
||||
);
|
||||
const resp = res.methodResponses?.[0];
|
||||
if (resp && resp[0] === 'VacationResponse/get') {
|
||||
const list = (resp[1] as { list?: VacationResponse[] }).list ?? [];
|
||||
return list[0] ?? DEFAULT;
|
||||
}
|
||||
throw new Error('Unexpected response from VacationResponse/get');
|
||||
}
|
||||
|
||||
export async function setVacationResponse(
|
||||
updates: Partial<Omit<VacationResponse, 'id'>>,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['VacationResponse/set', {
|
||||
accountId,
|
||||
update: { singleton: updates },
|
||||
}, '0']],
|
||||
VACATION_USING,
|
||||
);
|
||||
const resp = res.methodResponses?.[0];
|
||||
if (resp && resp[0] === 'VacationResponse/set') {
|
||||
const result = resp[1] as { notUpdated?: Record<string, { description?: string }> };
|
||||
if (result.notUpdated?.singleton) {
|
||||
throw new Error(result.notUpdated.singleton.description ?? 'Failed to update vacation responder');
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error('Unexpected response from VacationResponse/set');
|
||||
}
|
||||
|
||||
export function isVacationSupported(): boolean {
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) return false;
|
||||
const caps = session.capabilities ?? {};
|
||||
return CAPABILITIES.VACATION in caps;
|
||||
}
|
||||
Reference in New Issue
Block a user