mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c82130267 | ||
|
|
f228257ec9 | ||
|
|
a02e7f8a49 | ||
|
|
ea1f53f550 | ||
|
|
4e035f3011 | ||
|
|
e04bea451a | ||
|
|
5cb28f5d5d |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.59",
|
||||
"version": "0.1.62",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.59",
|
||||
"version": "0.1.62",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.59",
|
||||
"version": "0.1.62",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -14,6 +14,18 @@ const git = (...args) =>
|
||||
const gitInherit = (...args) =>
|
||||
execFileSync('git', args, { cwd: root, stdio: 'inherit' });
|
||||
|
||||
// Pre-flight: the release is created with the GitHub CLI as the last step.
|
||||
// Check it is installed and logged in BEFORE anything is committed, tagged or
|
||||
// pushed - otherwise a missing login leaves a pushed tag with no release (and
|
||||
// no release workflow run), which is exactly what a re-run cannot repair.
|
||||
try {
|
||||
execFileSync('gh', ['auth', 'status'], { cwd: root, stdio: 'ignore' });
|
||||
} catch {
|
||||
console.error('GitHub CLI is not available or not logged in.');
|
||||
console.error('Run `gh auth login` (or set GH_TOKEN), then re-run `npm run bump`.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dirty = git('status', '--porcelain');
|
||||
if (dirty) {
|
||||
console.error('Working tree is not clean. Commit or stash changes before bumping.');
|
||||
@@ -54,10 +66,16 @@ gitInherit('commit', '-m', `chore: update version number to ${next}`);
|
||||
gitInherit('tag', '-a', next, '-m', next);
|
||||
gitInherit('push', '--follow-tags');
|
||||
|
||||
execFileSync(
|
||||
'gh',
|
||||
['release', 'create', next, '--title', next, '--generate-notes'],
|
||||
{ cwd: root, stdio: 'inherit' },
|
||||
);
|
||||
try {
|
||||
execFileSync(
|
||||
'gh',
|
||||
['release', 'create', next, '--title', next, '--generate-notes'],
|
||||
{ cwd: root, stdio: 'inherit' },
|
||||
);
|
||||
} catch {
|
||||
console.error(`The tag ${next} is pushed but the GitHub release could not be created.`);
|
||||
console.error(`Fix the GitHub CLI login and run: gh release create ${next} --title ${next} --generate-notes`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Released ${next}`);
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Live integration test against a real Stalwart server.
|
||||
*
|
||||
* Opt-in: skipped unless `BULWARK_LIVE_JMAP` names a reachable JMAP host, so
|
||||
* the normal suite stays hermetic. It exercises the client code paths that
|
||||
* cannot be proven with mocks - the ones where a fixture only ever encodes
|
||||
* what we already believed the server does:
|
||||
*
|
||||
* BULWARK_LIVE_JMAP=http://127.0.0.1:18081 \
|
||||
* BULWARK_LIVE_USER=usera@example.org \
|
||||
* BULWARK_LIVE_PASS=… npx vitest run src/api/__tests__/live-stalwart.test.ts
|
||||
*
|
||||
* Verified against stalwartlabs/stalwart:v0.16.19.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
|
||||
import { JMAPClient } from '../jmap-client';
|
||||
import {
|
||||
getMailboxes,
|
||||
queryEmails,
|
||||
getFullEmails,
|
||||
getThreadEmails,
|
||||
createDraft,
|
||||
sendEmail,
|
||||
markAsSpam,
|
||||
undoSpam,
|
||||
emptyMailbox,
|
||||
markMailboxAsRead,
|
||||
createMailbox,
|
||||
deleteMailbox,
|
||||
patchKeywordsForEmails,
|
||||
EMAIL_FULL_PROPERTIES,
|
||||
} from '../email';
|
||||
import { getIdentities } from '../identity';
|
||||
import { getCalendars, queryEvents, createEvent, deleteEvents } from '../calendar';
|
||||
import { getAddressBooks, createContact, deleteContacts } from '../contacts';
|
||||
|
||||
const SERVER = process.env.BULWARK_LIVE_JMAP;
|
||||
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
|
||||
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
|
||||
const live = SERVER && PASS ? describe : describe.skip;
|
||||
|
||||
// The api modules talk to the exported singleton; swap its guts for a client
|
||||
// connected to the live server.
|
||||
let client: JMAPClient;
|
||||
|
||||
async function connectSingleton(): Promise<void> {
|
||||
const mod = await import('../jmap-client');
|
||||
client = new JMAPClient();
|
||||
await client.connect(SERVER!, USER, PASS);
|
||||
// The singleton is a const binding; copy the connected state onto it.
|
||||
(mod.jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot());
|
||||
}
|
||||
|
||||
live('live Stalwart', () => {
|
||||
let inbox = '';
|
||||
let drafts = '';
|
||||
let sent = '';
|
||||
let junk = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
await connectSingleton();
|
||||
const boxes = await getMailboxes();
|
||||
const byRole = (r: string) => boxes.find((b) => b.role === r)?.id ?? '';
|
||||
inbox = byRole('inbox');
|
||||
drafts = byRole('drafts');
|
||||
sent = byRole('sent');
|
||||
junk = byRole('junk');
|
||||
expect(inbox).toBeTruthy();
|
||||
}, 60_000);
|
||||
|
||||
describe('session', () => {
|
||||
it('rebases the advertised session URLs onto the connected origin', () => {
|
||||
// Stalwart advertises its configured hostname (https://stwtest.local/…),
|
||||
// which is unreachable from a client that connected by IP/port. Without
|
||||
// the rewrite every request after login fails.
|
||||
const session = client.currentSession!;
|
||||
expect(session.apiUrl.startsWith(SERVER!)).toBe(true);
|
||||
expect(session.downloadUrl.startsWith(SERVER!)).toBe(true);
|
||||
// …and the RFC 6570 templates must survive the rewrite.
|
||||
expect(session.downloadUrl).toContain('{accountId}');
|
||||
expect(session.downloadUrl).toContain('{blobId}');
|
||||
expect(session.eventSourceUrl).toContain('{types}');
|
||||
});
|
||||
|
||||
it('finds the Stalwart extension in accountCapabilities, not session capabilities (native #47)', () => {
|
||||
const session = client.currentSession!;
|
||||
expect('urn:stalwart:jmap' in (session.capabilities ?? {})).toBe(false);
|
||||
expect(client.hasAccountCapability('urn:stalwart:jmap')).toBe(true);
|
||||
// The pre-fix session-level check is what made Account Security dead.
|
||||
expect(client.hasCapability('urn:stalwart:jmap')).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the advertised request limits', () => {
|
||||
expect(client.getMaxObjectsInGet()).toBeGreaterThan(0);
|
||||
expect(client.getMaxObjectsInSet()).toBeGreaterThan(0);
|
||||
expect(client.getMaxSizeUpload()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects bad credentials with AuthenticationError', async () => {
|
||||
const bad = new JMAPClient();
|
||||
await expect(bad.connect(SERVER!, USER, 'definitely-not-the-password'))
|
||||
.rejects.toMatchObject({ name: 'AuthenticationError' });
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('mail', () => {
|
||||
it('fetches the threading headers the composer needs (#234)', async () => {
|
||||
expect(EMAIL_FULL_PROPERTIES).toContain('messageId');
|
||||
expect(EMAIL_FULL_PROPERTIES).toContain('references');
|
||||
expect(EMAIL_FULL_PROPERTIES).toContain('headers');
|
||||
|
||||
const { ids } = await queryEmails(inbox, { limit: 20 });
|
||||
const emails = await getFullEmails(ids);
|
||||
const reply = emails.find((e) => (e.subject ?? '').startsWith('Re: '));
|
||||
expect(reply, 'seeded reply message missing').toBeTruthy();
|
||||
// Bare msg-ids, no angle brackets (RFC 8621 §4.1.2.3).
|
||||
expect(reply!.messageId?.[0]).toMatch(/^[^<>]+@[^<>]+$/);
|
||||
expect(reply!.references?.length).toBeGreaterThan(0);
|
||||
expect(Array.isArray(reply!.headers)).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
it('exposes an HTML-only body as the same part in textBody and htmlBody', async () => {
|
||||
const { ids } = await queryEmails(inbox, { limit: 20 });
|
||||
const emails = await getFullEmails(ids);
|
||||
const news = emails.find((e) => (e.subject ?? '').includes('August digest'));
|
||||
expect(news, 'seeded HTML-only message missing').toBeTruthy();
|
||||
const textPart = news!.textBody?.[0];
|
||||
const htmlPart = news!.htmlBody?.[0];
|
||||
// The native #46 shape: identical partId, and the part is text/html.
|
||||
expect(textPart?.partId).toBe(htmlPart?.partId);
|
||||
expect(htmlPart?.type).toBe('text/html');
|
||||
|
||||
// A plain-text message reports the same partId too - only `type` tells
|
||||
// them apart, which is why the picker must route by type.
|
||||
const plain = emails.find((e) => (e.subject ?? '') === 'Quarterly numbers');
|
||||
expect(plain!.textBody?.[0]?.partId).toBe(plain!.htmlBody?.[0]?.partId);
|
||||
expect(plain!.htmlBody?.[0]?.type).toBe('text/plain');
|
||||
}, 60_000);
|
||||
|
||||
it('returns every message of a thread, oldest first', async () => {
|
||||
const { ids } = await queryEmails(inbox, { limit: 20 });
|
||||
const emails = await getFullEmails(ids);
|
||||
const reply = emails.find((e) => (e.subject ?? '').startsWith('Re: '))!;
|
||||
const thread = await getThreadEmails(reply.threadId);
|
||||
expect(thread.length).toBeGreaterThanOrEqual(2);
|
||||
const times = thread.map((e) => new Date(e.receivedAt).getTime());
|
||||
expect([...times].sort((a, b) => a - b)).toEqual(times);
|
||||
}, 60_000);
|
||||
|
||||
it('saves a draft into Drafts with $draft', async () => {
|
||||
const id = await createDraft(
|
||||
{
|
||||
from: [{ email: USER }],
|
||||
to: [{ email: 'userb@example.org' }],
|
||||
subject: 'Draft from the live test',
|
||||
textBody: 'draft body',
|
||||
},
|
||||
drafts,
|
||||
);
|
||||
const [saved] = await getFullEmails([id]);
|
||||
expect(saved.mailboxIds[drafts]).toBe(true);
|
||||
expect(saved.keywords.$draft).toBe(true);
|
||||
// Message-ID is generated client-side from the sender's domain.
|
||||
expect(saved.messageId?.[0]).toContain('@example.org');
|
||||
await patchKeywordsForEmails([id], { $seen: true });
|
||||
const { destroyEmails } = await import('../email');
|
||||
await destroyEmails([id]);
|
||||
}, 60_000);
|
||||
|
||||
it('sends through Drafts and files into Sent only after submission (#188)', async () => {
|
||||
const identities = await getIdentities();
|
||||
expect(identities.length).toBeGreaterThan(0);
|
||||
const result = await sendEmail(
|
||||
{
|
||||
from: [{ email: USER }],
|
||||
to: [{ email: 'userb@example.org' }],
|
||||
subject: 'Live send test',
|
||||
textBody: 'sent by the live integration test',
|
||||
inReplyTo: ['<root-1@partner.example>'],
|
||||
references: ['<root-1@partner.example>'],
|
||||
},
|
||||
identities[0].id,
|
||||
sent,
|
||||
undefined,
|
||||
{ draftsMailboxId: drafts },
|
||||
);
|
||||
expect(result.emailId).toBeTruthy();
|
||||
expect(result.emailSubmissionId).toBeTruthy();
|
||||
expect(result.filingWarning).toBeUndefined();
|
||||
|
||||
const [filed] = await getFullEmails([result.emailId!]);
|
||||
// onSuccessUpdateEmail moved it out of Drafts into Sent.
|
||||
expect(filed.mailboxIds[sent]).toBe(true);
|
||||
expect(filed.mailboxIds[drafts]).toBeUndefined();
|
||||
expect(filed.keywords.$draft).toBeUndefined();
|
||||
// Threading headers went out as bare msg-ids, not the JMAP id.
|
||||
expect(filed.inReplyTo).toEqual(['root-1@partner.example']);
|
||||
expect(filed.references).toEqual(['root-1@partner.example']);
|
||||
}, 90_000);
|
||||
|
||||
it('flips $junk/$notjunk when filing spam and undoing it (#850)', async () => {
|
||||
const { ids } = await queryEmails(inbox, { limit: 5 });
|
||||
const target = ids[0];
|
||||
await markAsSpam([target], junk, undefined, { markRead: true });
|
||||
let [msg] = await getFullEmails([target]);
|
||||
expect(msg.mailboxIds[junk]).toBe(true);
|
||||
expect(msg.keywords.$junk).toBe(true);
|
||||
expect(msg.keywords.$seen).toBe(true);
|
||||
|
||||
await undoSpam([target], inbox);
|
||||
[msg] = await getFullEmails([target]);
|
||||
expect(msg.mailboxIds[inbox]).toBe(true);
|
||||
expect(msg.mailboxIds[junk]).toBeUndefined();
|
||||
expect(msg.keywords.$junk).toBeUndefined();
|
||||
expect(msg.keywords.$notjunk).toBe(true);
|
||||
}, 90_000);
|
||||
|
||||
it('empties a folder and marks one read in batches', async () => {
|
||||
const scratch = await createMailbox({ name: `live-test-${Date.now()}` });
|
||||
try {
|
||||
// Nothing in it yet: the loop must terminate on an empty page.
|
||||
expect(await emptyMailbox(scratch)).toBe(0);
|
||||
expect(await markMailboxAsRead(scratch)).toBe(0);
|
||||
} finally {
|
||||
await deleteMailbox(scratch);
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('calendar', () => {
|
||||
it('queries a date window with the singular inCalendar filter', async () => {
|
||||
const cals = await getCalendars();
|
||||
expect(cals.length).toBeGreaterThan(0);
|
||||
const now = new Date();
|
||||
const from = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString();
|
||||
const to = new Date(now.getFullYear(), now.getMonth() + 2, 0).toISOString();
|
||||
const ids = await queryEvents(cals.map((c) => c.originalId ?? c.id), from, to);
|
||||
expect(Array.isArray(ids)).toBe(true);
|
||||
expect(ids.length).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
|
||||
it('creates a timed event with a time zone and destroys it', async () => {
|
||||
const cals = await getCalendars();
|
||||
const cal = cals[0];
|
||||
const start = new Date(Date.now() + 86_400_000);
|
||||
const created = await createEvent(
|
||||
{
|
||||
title: 'Live test event',
|
||||
start: start.toISOString().replace(/\.\d+Z$/, '').replace('T', 'T'),
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
},
|
||||
cal.originalId ?? cal.id,
|
||||
);
|
||||
expect(created.id).toBeTruthy();
|
||||
await deleteEvents([created.id]);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('contacts', () => {
|
||||
it('creates a card with a UID and an RFC 9553 PartialDate birthday (#644, #224)', async () => {
|
||||
const books = await getAddressBooks();
|
||||
expect(books.length).toBeGreaterThan(0);
|
||||
const book = books[0];
|
||||
const created = await createContact(
|
||||
{
|
||||
name: { components: [{ kind: 'given', value: 'Live' }, { kind: 'surname', value: 'Test' }] },
|
||||
emails: { e1: { address: `live-${Date.now()}@example.org` } },
|
||||
anniversaries: {
|
||||
a1: {
|
||||
'@type': 'Anniversary',
|
||||
kind: 'birth',
|
||||
// A raw "1985-04-12" string is rejected by Stalwart (#224).
|
||||
date: { '@type': 'PartialDate', year: 1985, month: 4, day: 12 },
|
||||
},
|
||||
},
|
||||
},
|
||||
book.originalId ?? book.id,
|
||||
);
|
||||
try {
|
||||
expect(created.id).toBeTruthy();
|
||||
// createContact re-reads the card, so the caller gets a full object.
|
||||
expect(created.uid).toBeTruthy();
|
||||
expect(created.name?.components?.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await deleteContacts([created.id]);
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
});
|
||||
+7
-1
@@ -1185,7 +1185,13 @@ export async function sendEmail(
|
||||
const props = notCreated.properties?.length ? ` (properties: ${notCreated.properties.join(', ')})` : '';
|
||||
throw new Error(`${notCreated.description ?? notCreated.type ?? 'Failed to create message'}${props}`);
|
||||
}
|
||||
emailId = (result as { created?: Record<string, { id?: string }> }).created?.draft?.id;
|
||||
// Stalwart answers a submission carrying `onSuccessUpdateEmail` with a
|
||||
// SECOND `Email/set` response (reusing the submission's call id) that
|
||||
// reports the filing update and carries no `created`. Only take an id
|
||||
// when one is actually there - assigning unconditionally overwrote the
|
||||
// real id with undefined, which silently disabled the undo-send window.
|
||||
const createdId = (result as { created?: Record<string, { id?: string }> }).created?.draft?.id;
|
||||
if (createdId) emailId = createdId;
|
||||
// Filing problems from onSuccessUpdateEmail come back on the implicit
|
||||
// Email/set; the message already left, so warn rather than fail.
|
||||
const notUpdated = (result as { notUpdated?: Record<string, { description?: string; type?: string }> }).notUpdated;
|
||||
|
||||
@@ -45,11 +45,21 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
|
||||
const mailAttachmentAction = useSettingsStore((s) => s.mailAttachmentAction);
|
||||
const exportOpts = useSettingsStore((s) => ({
|
||||
spaceReplacement: s.exportSpaceReplacement,
|
||||
lowercase: s.exportLowercase,
|
||||
stripDiacritics: s.exportStripDiacritics,
|
||||
}));
|
||||
// Select primitives, not a fresh object: zustand v5 compares snapshots with
|
||||
// Object.is, so a selector returning a new object literal on every call
|
||||
// reports a change on every render - React then re-renders until it gives
|
||||
// up with "Maximum update depth exceeded" (it crashed the whole viewer).
|
||||
const exportSpaceReplacement = useSettingsStore((s) => s.exportSpaceReplacement);
|
||||
const exportLowercase = useSettingsStore((s) => s.exportLowercase);
|
||||
const exportStripDiacritics = useSettingsStore((s) => s.exportStripDiacritics);
|
||||
const exportOpts = React.useMemo(
|
||||
() => ({
|
||||
spaceReplacement: exportSpaceReplacement,
|
||||
lowercase: exportLowercase,
|
||||
stripDiacritics: exportStripDiacritics,
|
||||
}),
|
||||
[exportSpaceReplacement, exportLowercase, exportStripDiacritics],
|
||||
);
|
||||
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const [busyKey, setBusyKey] = React.useState<string | null>(null);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import { Linking } from 'react-native';
|
||||
import { useSettingsStore, type SidebarApp } from '../stores/settings-store';
|
||||
|
||||
@@ -7,7 +8,11 @@ import { useSettingsStore, type SidebarApp } from '../stores/settings-store';
|
||||
* edits them. Mirrors the webmail's navigation-rail filter on `showOnMobile`.
|
||||
*/
|
||||
export function useMobileSidebarApps(): SidebarApp[] {
|
||||
return useSettingsStore((s) => s.sidebarApps.filter((app) => app.showOnMobile));
|
||||
// `.filter()` in the selector would allocate a new array per call, which
|
||||
// zustand v5 reads as a changed snapshot on every render (infinite loop).
|
||||
// Subscribe to the stored array and derive from it.
|
||||
const apps = useSettingsStore((s) => s.sidebarApps);
|
||||
return React.useMemo(() => apps.filter((app) => app.showOnMobile), [apps]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user