Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 e344c57cd6 feat: prompt to create or pick folder when archive is missing (#286)
When archiving selected mails and the archive folder doesn't exist, show
a dialog asking the user to either choose an existing folder or create a
new one. When creating, the JMAP role is sent to the server and the
IMAP/JMAP role is stored in the local DB so the folder is found by role
on the next operation.

Also preserves manually-set roles across IMAP syncs: if the server
doesn't return a special-use attribute for a folder that was manually
assigned a role, the local role is retained.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 19:00:47 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 04a5ec4b23 feat: auto-merge safe Renovate PRs via CI (#277)
Configure Renovate to label patch/minor/pin/digest/lockFileMaintenance
updates with 'automerge'. Add a merge-renovate job to ci.yml that runs
after the check job passes on Renovate branches and merges PRs carrying
that label; major-version PRs are left for manual review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 09:36:23 +02:00
Bot of Thomas Güttler 73bbfd2694 fix: add explicit note that app settings are never uploaded (#280) (#281) 2026-05-27 08:25:20 +02:00
Thomas SharedInbox 49e6b335d9 better err msg in agent-loop. 2026-05-27 08:14:42 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 e8234981c5 fix(renovate): run sed as root to patch read-only dist files
The /usr/local/renovate/dist directory is owned by root.
Temporarily switch to root for the sed patch, then back to ubuntu.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:55:31 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 cf94c7c1fb fix(renovate): patch forgejo+gitea pr-cache.js at /dist/ path
Files are under dist/ not lib/, and we need to patch both
forgejo and gitea platform caches since platform=forgejo is set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:39:13 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 92183a3eb2 chore(renovate): diagnostic step to find pr-cache.js location
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:29:09 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 4e8a5ff968 fix(renovate): use find to locate pr-cache.js before patching
The file is not at the assumed path; use find to locate it first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:19:48 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 33f1c5a9d4 fix(renovate): patch pr-cache.js to use limit=10 for Codeberg
Codeberg's API times out (504) on GET /pulls?state=all&limit=100
but completes in ~9s at limit=10. Patch the compiled pr-cache.js
in the renovate:43 image before running to replace the hardcoded
20/100 page sizes with 10.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:18:02 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 0552b7a48c fix(renovate): pre-seed PR cache to avoid Codeberg 504 on initial sync
Codeberg's API times out (504) when fetching 100 closed PRs
(GET /pulls?state=all&limit=100), but succeeds with limit=20.
Renovate uses limit=100 on the first run and limit=20 on incremental
syncs. Pre-seeding the repository cache with one dummy entry tricks
Renovate into using the limit=20 incremental path from the start.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:09:41 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 2f0da5b475 fix(renovate): upgrade to renovate:43 with forgejo platform
renovate/renovate:39 did not support "forgejo" as a platform name;
v43 does. Upgrade the image and restore the correct platform name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:28:15 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 a1f8bb5994 fix: use RENOVATE_PLATFORM=gitea for renovate/renovate:39
renovate/renovate:39 does not recognise "forgejo" as a platform name;
the correct value is "gitea", which covers Forgejo/Gitea instances
including Codeberg.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:27:15 +02:00
Bot of Thomas Güttler 6714e330cc Merge pull request 'feat: run Firebase tests once daily via dedicated workflow (#272)' (#273) from issue-272-fix into main 2026-05-26 17:20:37 +02:00
Thomas SharedInbox a8d6ec5861 fix: use commit_sha instead of head_sha to detect already-deployed commits
Forgejo's API returns head_sha=null in workflow run objects; the correct
field is commit_sha. The skip-check always got None, so every hourly
schedule triggered a full redeploy of the same commit.
2026-05-26 15:22:23 +02:00
Thomas SharedInbox 491a220fbb fix: use commit_sha instead of head_sha to detect already-deployed commits
Forgejo's API returns head_sha=null in workflow run objects; the correct
field is commit_sha. The skip-check always got None, so every hourly
schedule triggered a full redeploy of the same commit.
2026-05-26 15:21:50 +02:00
Thomas SharedInbox e22c4aa88d fix: use Dagger for website deploy and record Renovate Bot completion (#267, #268) 2026-05-26 15:09:59 +02:00
Thomas SharedInbox 4bc24072f0 feat: run Firebase tests once daily via dedicated workflow (#272) 2026-05-26 15:09:55 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 720c54433a feat: run Firebase tests once daily via dedicated workflow (#272)
Move Android Firebase instrumented tests out of deploy.yml into a new
firebase-tests.yml workflow that runs once per day (3 AM UTC) and only
when Firebase-relevant files changed in the last 24 hours. On failure,
the workflow automatically creates a Forgejo issue labelled "Ready" with
instructions to find the root cause and fix it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 08:48:10 +02:00
Bot of Thomas Güttler f57a8c502d feat: syncLog add Copy button, stack trace, isPermanent (#266) (#269) 2026-05-26 07:55:07 +02:00
19 changed files with 1057 additions and 262 deletions
+48
View File
@@ -109,3 +109,51 @@ jobs:
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
merge-renovate:
name: Auto-merge Renovate PR
needs: [check]
if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'renovate/')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Merge if automerge label is set
env:
FORGEJO_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error, sys
token = os.environ["FORGEJO_TOKEN"]
url_base = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "")
pr_number = os.environ["PR_NUMBER"]
api = f"{url_base}/api/v1/repos/{repo}"
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
req = urllib.request.Request(f"{api}/issues/{pr_number}/labels", headers=headers)
with urllib.request.urlopen(req) as r:
labels = [l["name"] for l in json.loads(r.read())]
if "automerge" not in labels:
print(f"PR #{pr_number}: no 'automerge' label — major update, skipping")
sys.exit(0)
body = json.dumps({"Do": "merge"}).encode()
req = urllib.request.Request(
f"{api}/pulls/{pr_number}/merge",
data=body, headers=headers, method="POST"
)
try:
with urllib.request.urlopen(req) as r:
print(f"PR #{pr_number} merged successfully")
except urllib.error.HTTPError as e:
err = e.read().decode()
if "already been merged" in err or "has been merged" in err:
print(f"PR #{pr_number} already merged — OK")
else:
print(f"Merge failed: {err}")
sys.exit(1)
PYEOF
+3 -42
View File
@@ -50,7 +50,7 @@ jobs:
r for r in data.get("workflow_runs", [])
if r.get("workflow_id") == "deploy.yml" and r.get("status") == "success"
]
print(runs[0]["head_sha"] if runs else "")
print(runs[0].get("commit_sha") or "")
except Exception as e:
print(f"API check failed: {e}", file=sys.stderr)
print("")
@@ -83,44 +83,6 @@ jobs:
&& echo "linux=true" >> "$GITHUB_OUTPUT" \
|| echo "linux=false" >> "$GITHUB_OUTPUT"
test-android-firebase:
name: Android Instrumented Tests (Firebase Test Lab)
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.android == 'true'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Run Android Tests on Firebase Test Lab
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
env:
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
DAGGER_NO_NAG: "1"
run: task test-android-firebase
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
deploy-playstore:
name: Build & Deploy to Play Store
runs-on: ubuntu-latest
@@ -287,10 +249,9 @@ jobs:
label-deploy-health:
name: Update Deploy Health Label
runs-on: ubuntu-latest
needs: [test-android-firebase, deploy-playstore, deploy-apk, build-linux]
needs: [deploy-playstore, deploy-apk, build-linux]
if: |
always() && vars.DEPLOY_HEALTH_ISSUE != '' && (
needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'failure' ||
needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' ||
needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' ||
needs.build-linux.result == 'success' || needs.build-linux.result == 'failure'
@@ -303,7 +264,7 @@ jobs:
FORGEJO_TOKEN: ${{ github.token }}
FORGEJO_URL: ${{ github.server_url }}
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
ALL_SUCCEEDED: ${{ (needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'skipped') && (needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'skipped') && (needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') }}
ALL_SUCCEEDED: ${{ (needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'skipped') && (needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') }}
run: |
python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error
+132
View File
@@ -0,0 +1,132 @@
name: Firebase Tests
on:
schedule:
- cron: '0 3 * * *' # once per day at 3 AM
workflow_dispatch:
jobs:
check-changes:
name: Detect Firebase-Relevant Changes
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
has_changes: ${{ steps.diff.outputs.has_changes }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect Firebase-relevant changes in last 24 hours
id: diff
shell: bash
run: |
# On workflow_dispatch always run
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
echo "has_changes=true" >> "$GITHUB_OUTPUT"
exit 0
fi
SINCE=$(date -u -d '24 hours ago' '+%Y-%m-%dT%H:%M:%S')
CHANGED=$(git log --since="$SINCE" --name-only --format= -- \
'android/' 'integration_test/' 'lib/' 'pubspec.yaml' 'pubspec.lock' 'drift_schemas/' \
| sort -u | grep -v '^$')
if [ -n "$CHANGED" ]; then
echo "Firebase-relevant files changed since $SINCE:"
echo "$CHANGED"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
else
echo "No Firebase-relevant changes in the last 24 hours — skipping tests"
echo "has_changes=false" >> "$GITHUB_OUTPUT"
fi
test-android-firebase:
name: Android Instrumented Tests (Firebase Test Lab)
runs-on: ubuntu-latest
timeout-minutes: 60
needs: [check-changes]
if: needs.check-changes.outputs.has_changes == 'true'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Run Android Tests on Firebase Test Lab
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
env:
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
DAGGER_NO_NAG: "1"
run: task test-android-firebase
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
- name: Create issue on test failure
if: failure()
env:
FORGEJO_TOKEN: ${{ github.token }}
FORGEJO_URL: ${{ github.server_url }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error
token = os.environ["FORGEJO_TOKEN"]
url_base = os.environ["FORGEJO_URL"].rstrip("/")
run_url = os.environ["RUN_URL"]
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
api = f"{url_base}/api/v1/repos/guettli/sharedinbox"
def api_get(path):
req = urllib.request.Request(f"{api}{path}", headers=headers)
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
def api_post(path, body):
data = json.dumps(body).encode()
req = urllib.request.Request(f"{api}{path}", data=data, headers=headers, method="POST")
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
repo_labels = api_get("/labels")
label_map = {l["name"]: l["id"] for l in repo_labels}
label_ids = [label_map["Ready"]] if "Ready" in label_map else []
title = "Firebase Tests failed — find root cause and fix"
body = (
"Firebase instrumented tests failed in the daily run.\n\n"
f"**Failed run:** {run_url}\n\n"
"## Steps to resolve\n\n"
"1. **Find the root cause**: Check the test run logs linked above and identify which test(s) failed and why.\n"
"2. **Fix if possible**: If the failure is caused by a code bug, create a fix. If it is a flaky or infrastructure issue, document the findings.\n"
"3. Close this issue once the root cause is resolved and the tests pass.\n"
)
issue = api_post("/issues", {
"title": title,
"body": body,
"labels": label_ids,
})
print(f"Created issue #{issue['number']}: {issue['html_url']}")
PYEOF
+2 -2
View File
@@ -33,12 +33,12 @@ repos:
- id: ci-no-direct-dagger
name: check for direct dagger calls in workflows (use Task instead)
language: system
entry: "bash -c 'git grep \"dagger call\" .forgejo/workflows/ && echo \"ERROR: Direct dagger calls found in workflows. Use Taskfile instead.\" && exit 1 || exit 0'"
entry: "bash -c 'git --no-pager grep \"dagger call\" .forgejo/workflows/ && echo \"ERROR: Direct dagger calls found in workflows. Use Taskfile instead.\" && exit 1 || exit 0'"
pass_filenames: false
always_run: true
- id: dagger-progress-plain
name: ensure all dagger calls use --progress=plain
language: system
entry: "bash -c 'git grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
entry: "bash -c 'git --no-pager grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
pass_filenames: false
always_run: true
+12 -1
View File
@@ -844,13 +844,24 @@ func (m *Ci) PublishAndroid(
// Renovate runs Renovate bot against the repository on Forgejo/Codeberg.
func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string, error) {
// Codeberg's GET /pulls?state=all&limit=100 times out with a 504, but limit=10
// completes in ~9 s. Patch the compiled pr-cache.js to use 10 instead of the
// hardcoded 20/100 values before launching renovate.
const patchCmd = `for f in \
/usr/local/renovate/dist/modules/platform/forgejo/pr-cache.js \
/usr/local/renovate/dist/modules/platform/gitea/pr-cache.js; do \
sed -i 's/limit: this\.items\.length ? 20 : 100/limit: this.items.length ? 10 : 10/' "$f" && echo "patched $f"; \
done`
return dag.Container().
From("renovate/renovate:39").
From("renovate/renovate:43").
WithSecretVariable("RENOVATE_TOKEN", renovateToken).
WithEnvVariable("RENOVATE_PLATFORM", "forgejo").
WithEnvVariable("RENOVATE_ENDPOINT", "https://codeberg.org").
WithEnvVariable("RENOVATE_REPOSITORIES", "guettli/sharedinbox").
WithEnvVariable("LOG_LEVEL", "info").
WithUser("root").
WithExec([]string{"/bin/sh", "-c", patchCmd}).
WithUser("ubuntu").
WithExec([]string{"renovate"}).
Stdout(ctx)
}
@@ -11,4 +11,13 @@ abstract class MailboxRepository {
/// Deletes all locally-cached mailbox rows for [accountId].
Future<void> clearForResync(String accountId);
/// Creates a new mailbox named [name] for [accountId] and tags it with
/// [role] in the local database. For JMAP accounts the role is also sent
/// to the server. Returns the newly created [Mailbox].
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
);
}
@@ -79,6 +79,14 @@ class MailboxRepositoryImpl implements MailboxRepository {
);
try {
final mailboxes = await client.listMailboxes(recursive: true);
// Pre-load existing DB roles so we can preserve manually-set roles for
// folders the server doesn't tag with a special-use attribute.
final existingRows = await (_db.select(_db.mailboxes)
..where((t) => t.accountId.equals(account.id)))
.get();
final existingRoles = {for (final r in existingRows) r.id: r.role};
for (final mb in mailboxes) {
final path = mb.path;
final id = '${account.id}:$path';
@@ -96,6 +104,12 @@ class MailboxRepositoryImpl implements MailboxRepository {
log('STATUS skipped for $path: $e');
}
// Use the server-assigned role when available; fall back to the
// existing DB role so that manually-created folders (e.g. a user
// who just created their Archive folder) keep their role across syncs
// when the IMAP server does not expose a special-use attribute.
final role = _imapRole(mb) ?? existingRoles[id];
await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert(
id: id,
@@ -104,7 +118,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
name: mb.name,
unreadCount: Value(unread),
totalCount: Value(total),
role: Value(_imapRole(mb)),
role: Value(role),
),
);
}
@@ -310,4 +324,104 @@ class MailboxRepositoryImpl implements MailboxRepository {
..where((t) => t.accountId.equals(accountId)))
.go();
}
@override
Future<model.Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async {
final account = (await _accounts.getAccount(accountId))!;
final password = await _accounts.getPassword(accountId);
switch (account.type) {
case account_model.AccountType.imap:
return _createMailboxWithRoleImap(account, password, name, role);
case account_model.AccountType.jmap:
return _createMailboxWithRoleJmap(account, password, name, role);
}
}
Future<model.Mailbox> _createMailboxWithRoleImap(
account_model.Account account,
String password,
String name,
String role,
) async {
final client = await _imapConnect(
account,
_effectiveUsername(account),
password,
);
try {
await client.createMailbox(name);
} finally {
await client.logout();
}
final id = '${account.id}:$name';
await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert(
id: id,
accountId: account.id,
path: name,
name: name,
role: Value(role),
),
);
final row = await (_db.select(_db.mailboxes)..where((t) => t.id.equals(id)))
.getSingle();
return _toModel(row);
}
Future<model.Mailbox> _createMailboxWithRoleJmap(
account_model.Account account,
String password,
String name,
String role,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) {
throw Exception('JMAP account ${account.id} has no jmapUrl');
}
final jmap = await JmapClient.connect(
httpClient: _httpClient,
jmapUrl: Uri.parse(jmapUrl),
username: _effectiveUsername(account),
password: password,
);
final responses = await jmap.call([
[
'Mailbox/set',
{
'accountId': jmap.accountId,
'create': {
'new-mailbox': {'name': name, 'role': role},
},
},
'0',
],
]);
final result = _responseArgs(responses, 0, 'Mailbox/set');
final created = result['created'] as Map<String, dynamic>?;
final newId =
(created?['new-mailbox'] as Map<String, dynamic>?)?['id'] as String?;
if (newId == null) {
throw Exception(
'Failed to create mailbox "$name": server returned no ID',
);
}
final dbId = '${account.id}:$newId';
await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert(
id: dbId,
accountId: account.id,
path: newId,
name: name,
role: Value(role),
),
);
final row = await (_db.select(_db.mailboxes)
..where((t) => t.id.equals(dbId)))
.getSingle();
return _toModel(row);
}
}
+79 -15
View File
@@ -7,6 +7,7 @@ import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart';
@@ -24,6 +25,8 @@ int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
String _fmtDate(DateTime dt) =>
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
enum _MissingFolderChoice { chooseExisting, createNew }
class EmailListScreen extends ConsumerStatefulWidget {
const EmailListScreen({
super.key,
@@ -420,24 +423,79 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
);
}
Future<void> _batchMoveToRole(String role, String notFoundMessage) async {
Future<void> _batchMoveToRole(
String role, {
required String dialogTitle,
required String createFolderName,
}) async {
final ids = _selectedEmailIds;
_clearSelection();
final mailbox = await ref
.read(mailboxRepositoryProvider)
.findMailboxByRole(widget.accountId, role);
final mailboxRepo = ref.read(mailboxRepositoryProvider);
Mailbox? mailbox =
await mailboxRepo.findMailboxByRole(widget.accountId, role);
if (!mounted) return;
if (mailbox == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text(notFoundMessage),
final choice = await showDialog<_MissingFolderChoice>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(dialogTitle),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(ctx, _MissingFolderChoice.chooseExisting),
child: const Text('Choose existing folder'),
),
FilledButton(
onPressed: () =>
Navigator.pop(ctx, _MissingFolderChoice.createNew),
child: Text('Create "$createFolderName"'),
),
],
),
);
return;
if (!mounted || choice == null) return;
switch (choice) {
case _MissingFolderChoice.chooseExisting:
final mailboxes =
await mailboxRepo.observeMailboxes(widget.accountId).first;
if (!mounted) return;
final chosen = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => ListView(
shrinkWrap: true,
children: [
const ListTile(
title: Text(
'Move to…',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
for (final m
in mailboxes.where((m) => m.path != widget.mailboxPath))
ListTile(
leading: const Icon(Icons.folder_outlined),
title: Text(m.name),
onTap: () => Navigator.pop(ctx, m.path),
),
],
),
);
if (chosen == null || !mounted) return;
mailbox = mailboxes.firstWhere((m) => m.path == chosen);
case _MissingFolderChoice.createNew:
mailbox = await mailboxRepo.createMailboxWithRole(
widget.accountId,
createFolderName,
role,
);
if (!mounted) return;
}
}
final repo = ref.read(emailRepositoryProvider);
// Fetch full email data before moving so we can restore them if user clicks Undo.
@@ -463,8 +521,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
Future<void> _batchArchive() =>
_batchMoveToRole('archive', 'No archive folder found');
Future<void> _batchArchive() => _batchMoveToRole(
'archive',
dialogTitle: 'No archive folder found',
createFolderName: 'Archive',
);
Future<void> _refreshSearchAndPopIfEmpty() async {
if (!mounted || !_searching) return;
@@ -543,8 +604,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
}
}
Future<void> _batchMarkSpam() =>
_batchMoveToRole('junk', 'No spam folder found');
Future<void> _batchMarkSpam() => _batchMoveToRole(
'junk',
dialogTitle: 'No spam folder found',
createFolderName: 'Junk',
);
Future<void> _batchMove() async {
final ids = _selectedEmailIds;
+7 -1
View File
@@ -6,5 +6,11 @@
"labels": ["dependencies"],
"github-actions": {
"fileMatch": ["^\\.forgejo/workflows/[^/]+\\.ya?ml$"]
}
},
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch", "pin", "digest", "lockFileMaintenance"],
"addLabels": ["automerge"]
}
]
}
+53 -42
View File
@@ -46,7 +46,7 @@ import time
from datetime import datetime, timezone
from pathlib import Path
# Cron runs with a minimal PATH; ensure Nix profile binaries (tea, claude) and ~/go/bin (fgj) are found.
# Cron runs with a minimal PATH; ensure Nix profile binaries (claude) and ~/go/bin (fgj) are found.
os.environ["PATH"] = (
f"{Path.home()}/.nix-profile/bin"
f":{Path.home()}/go/bin"
@@ -97,22 +97,27 @@ def _fgj(*args: str) -> None:
)
def _tea_get(path: str) -> dict | list | None:
"""Run a tea api GET and return parsed JSON. Only use for reads — tea PATCH/PUT
silently fails (exits 0) when unauthenticated, so writes must go via fgj."""
cmd = ["tea", "api", path]
result = subprocess.run(cmd, capture_output=True, text=True)
def _fgj_run_list(limit: int = 20) -> list[dict]:
"""Return workflow runs via fgj actions run list."""
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "actions", "run", "list",
"--repo", REPO, "--json", "-L", str(limit)],
capture_output=True, text=True,
)
if result.returncode != 0:
raise RuntimeError(
f"tea api {path} failed:\n{result.stderr or result.stdout}"
f"fgj actions run list failed:\n{result.stderr or result.stdout}"
)
out = result.stdout.strip()
if not out:
return None
data = json.loads(out)
if isinstance(data, dict) and "message" in data and "url" in data:
raise RuntimeError(f"tea api {path} returned error: {data['message']}")
return data
return []
try:
data = json.loads(out)
except json.JSONDecodeError as exc:
raise RuntimeError(
f"fgj actions run list returned non-JSON:\n{out[:500]}"
) from exc
return data if isinstance(data, list) else []
def _set_labels(issue: int, add: list[str], remove: list[str]) -> None:
@@ -181,9 +186,7 @@ def _latest_main_ci_run() -> dict | None:
event=push and prettyref=main, so filtering by event alone is not enough.
We also require workflow_id == "ci.yml".
"""
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
runs = (data or {}).get("workflow_runs", [])
for run in runs:
for run in _fgj_run_list(limit=20):
if (run.get("event") == "push"
and run.get("prettyref") == "main"
and run.get("workflow_id") == "ci.yml"):
@@ -194,20 +197,16 @@ def _latest_main_ci_run() -> dict | None:
def _latest_ci_run_for_branch(branch: str) -> dict | None:
"""Return the latest CI run for a specific branch, or None.
Forgejo's workflow_runs API has no top-level head_branch field.
For push events the branch is in ``prettyref``; for pull_request
events it lives inside ``event_payload["pull_request"]["head"]["ref"]``.
For push events fgj reports the branch in ``prettyref``; for pull_request
events ``prettyref`` is ``#N``, so we resolve the PR number first.
"""
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
runs = (data or {}).get("workflow_runs", [])
runs = _fgj_run_list(limit=20)
pr_data = _find_pr_for_branch(branch)
pr_ref = f"#{pr_data['number']}" if pr_data else None
for run in runs:
if run.get("event") == "pull_request":
try:
payload = json.loads(run.get("event_payload", "{}"))
if payload.get("pull_request", {}).get("head", {}).get("ref") == branch:
return run
except (json.JSONDecodeError, AttributeError):
pass
if pr_ref and run.get("prettyref") == pr_ref:
return run
elif run.get("event") == "push":
if run.get("prettyref") == branch:
return run
@@ -254,24 +253,27 @@ def _open_issue_prs() -> list[dict]:
def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
"""Return the latest CI run triggered by a pull_request event for the given PR number."""
data = _tea_get(f"repos/{REPO}/actions/runs?event=pull_request&limit=50")
runs = (data or {}).get("workflow_runs", [])
for run in runs:
try:
payload = json.loads(run.get("event_payload", "{}"))
if payload.get("pull_request", {}).get("number") == pr_number:
return run
except (json.JSONDecodeError, AttributeError):
pass
pr_ref = f"#{pr_number}"
for run in _fgj_run_list(limit=50):
if run.get("event") == "pull_request" and run.get("prettyref") == pr_ref:
return run
return None
def _get_issue_labels(issue: int) -> list[str]:
"""Return label names for an issue."""
data = _tea_get(f"repos/{REPO}/issues/{issue}")
if not data:
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "issue", "view", str(issue),
"--repo", REPO, "--json"],
capture_output=True, text=True,
)
if result.returncode != 0 or not result.stdout.strip():
return []
return [lbl["name"] for lbl in data.get("labels", [])]
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
return []
return [lbl["name"] for lbl in data.get("issue", {}).get("labels", [])]
def _merge_pr(pr_number: int) -> None:
@@ -287,8 +289,18 @@ def _handle_pr_still_open_after_merge(pr_number: int, branch: str, issue_num: in
"merged" — PR closed after a retry
"fallback" — all options exhausted; caller should set State/Question
"""
pr_data = _tea_get(f"repos/{REPO}/pulls/{pr_number}")
mergeable = (pr_data or {}).get("mergeable")
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "pr", "view", str(pr_number),
"--repo", REPO, "--json"],
capture_output=True, text=True,
)
pr_data: dict = {}
if result.returncode == 0 and result.stdout.strip():
try:
pr_data = json.loads(result.stdout)
except json.JSONDecodeError:
pass
mergeable = pr_data.get("mergeable")
if mergeable is False:
prompt = (
@@ -831,9 +843,8 @@ def _run_loop() -> int:
# spawning another agent, check whether any CI run is currently in
# progress (the branch run) and wait if so.
if ci_run_id_at_start is not None and run["id"] == ci_run_id_at_start:
check = _tea_get(f"repos/{REPO}/actions/runs?limit=5")
in_flight = [
r for r in (check or {}).get("workflow_runs", [])
r for r in _fgj_run_list(limit=5)
if r.get("status") == "running"
]
if in_flight:
@@ -149,6 +149,22 @@ class _FakeMailboxes implements MailboxRepository {
@override
Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async =>
Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
}
class _FakeEmails implements EmailRepository {
+15
View File
@@ -224,6 +224,21 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
Future<Mailbox?> findMailboxByRole(String id, String role) async => null;
@override
Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async =>
Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
}
class _AccountRepositoryWithMissingPlugin implements AccountRepository {
+195 -157
View File
@@ -3,16 +3,16 @@
// Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _i4;
import 'dart:async' as _i5;
import 'package:mockito/mockito.dart' as _i1;
import 'package:mockito/src/dummies.dart' as _i6;
import 'package:sharedinbox/core/models/account.dart' as _i5;
import 'package:sharedinbox/core/models/email.dart' as _i2;
import 'package:sharedinbox/core/models/mailbox.dart' as _i8;
import 'package:sharedinbox/core/repositories/account_repository.dart' as _i3;
import 'package:mockito/src/dummies.dart' as _i7;
import 'package:sharedinbox/core/models/account.dart' as _i6;
import 'package:sharedinbox/core/models/email.dart' as _i3;
import 'package:sharedinbox/core/models/mailbox.dart' as _i2;
import 'package:sharedinbox/core/repositories/account_repository.dart' as _i4;
import 'package:sharedinbox/core/repositories/email_repository.dart' as _i9;
import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i7;
import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i8;
// ignore_for_file: type=lint
// ignore_for_file: avoid_redundant_argument_values
@@ -29,8 +29,8 @@ import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i7;
// ignore_for_file: subtype_of_sealed_class
// ignore_for_file: invalid_use_of_internal_member
class _FakeEmailBody_0 extends _i1.SmartFake implements _i2.EmailBody {
_FakeEmailBody_0(
class _FakeMailbox_0 extends _i1.SmartFake implements _i2.Mailbox {
_FakeMailbox_0(
Object parent,
Invocation parentInvocation,
) : super(
@@ -39,9 +39,8 @@ class _FakeEmailBody_0 extends _i1.SmartFake implements _i2.EmailBody {
);
}
class _FakeSyncEmailsResult_1 extends _i1.SmartFake
implements _i2.SyncEmailsResult {
_FakeSyncEmailsResult_1(
class _FakeEmailBody_1 extends _i1.SmartFake implements _i3.EmailBody {
_FakeEmailBody_1(
Object parent,
Invocation parentInvocation,
) : super(
@@ -50,9 +49,20 @@ class _FakeSyncEmailsResult_1 extends _i1.SmartFake
);
}
class _FakeReliabilityResult_2 extends _i1.SmartFake
implements _i2.ReliabilityResult {
_FakeReliabilityResult_2(
class _FakeSyncEmailsResult_2 extends _i1.SmartFake
implements _i3.SyncEmailsResult {
_FakeSyncEmailsResult_2(
Object parent,
Invocation parentInvocation,
) : super(
parent,
parentInvocation,
);
}
class _FakeReliabilityResult_3 extends _i1.SmartFake
implements _i3.ReliabilityResult {
_FakeReliabilityResult_3(
Object parent,
Invocation parentInvocation,
) : super(
@@ -64,32 +74,32 @@ class _FakeReliabilityResult_2 extends _i1.SmartFake
/// A class which mocks [AccountRepository].
///
/// See the documentation for Mockito's code generation for more information.
class MockAccountRepository extends _i1.Mock implements _i3.AccountRepository {
class MockAccountRepository extends _i1.Mock implements _i4.AccountRepository {
MockAccountRepository() {
_i1.throwOnMissingStub(this);
}
@override
_i4.Stream<List<_i5.Account>> observeAccounts() => (super.noSuchMethod(
_i5.Stream<List<_i6.Account>> observeAccounts() => (super.noSuchMethod(
Invocation.method(
#observeAccounts,
[],
),
returnValue: _i4.Stream<List<_i5.Account>>.empty(),
) as _i4.Stream<List<_i5.Account>>);
returnValue: _i5.Stream<List<_i6.Account>>.empty(),
) as _i5.Stream<List<_i6.Account>>);
@override
_i4.Future<_i5.Account?> getAccount(String? id) => (super.noSuchMethod(
_i5.Future<_i6.Account?> getAccount(String? id) => (super.noSuchMethod(
Invocation.method(
#getAccount,
[id],
),
returnValue: _i4.Future<_i5.Account?>.value(),
) as _i4.Future<_i5.Account?>);
returnValue: _i5.Future<_i6.Account?>.value(),
) as _i5.Future<_i6.Account?>);
@override
_i4.Future<void> addAccount(
_i5.Account? account,
_i5.Future<void> addAccount(
_i6.Account? account,
String? password,
) =>
(super.noSuchMethod(
@@ -100,13 +110,13 @@ class MockAccountRepository extends _i1.Mock implements _i3.AccountRepository {
password,
],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i4.Future<void> updateAccount(
_i5.Account? account, {
_i5.Future<void> updateAccount(
_i6.Account? account, {
String? password,
}) =>
(super.noSuchMethod(
@@ -115,65 +125,65 @@ class MockAccountRepository extends _i1.Mock implements _i3.AccountRepository {
[account],
{#password: password},
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i4.Future<void> removeAccount(String? id) => (super.noSuchMethod(
_i5.Future<void> removeAccount(String? id) => (super.noSuchMethod(
Invocation.method(
#removeAccount,
[id],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i4.Future<String> getPassword(String? accountId) => (super.noSuchMethod(
_i5.Future<String> getPassword(String? accountId) => (super.noSuchMethod(
Invocation.method(
#getPassword,
[accountId],
),
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
this,
Invocation.method(
#getPassword,
[accountId],
),
)),
) as _i4.Future<String>);
) as _i5.Future<String>);
}
/// A class which mocks [MailboxRepository].
///
/// See the documentation for Mockito's code generation for more information.
class MockMailboxRepository extends _i1.Mock implements _i7.MailboxRepository {
class MockMailboxRepository extends _i1.Mock implements _i8.MailboxRepository {
MockMailboxRepository() {
_i1.throwOnMissingStub(this);
}
@override
_i4.Stream<List<_i8.Mailbox>> observeMailboxes(String? accountId) =>
_i5.Stream<List<_i2.Mailbox>> observeMailboxes(String? accountId) =>
(super.noSuchMethod(
Invocation.method(
#observeMailboxes,
[accountId],
),
returnValue: _i4.Stream<List<_i8.Mailbox>>.empty(),
) as _i4.Stream<List<_i8.Mailbox>>);
returnValue: _i5.Stream<List<_i2.Mailbox>>.empty(),
) as _i5.Stream<List<_i2.Mailbox>>);
@override
_i4.Future<int> syncMailboxes(String? accountId) => (super.noSuchMethod(
_i5.Future<int> syncMailboxes(String? accountId) => (super.noSuchMethod(
Invocation.method(
#syncMailboxes,
[accountId],
),
returnValue: _i4.Future<int>.value(0),
) as _i4.Future<int>);
returnValue: _i5.Future<int>.value(0),
) as _i5.Future<int>);
@override
_i4.Future<_i8.Mailbox?> findMailboxByRole(
_i5.Future<_i2.Mailbox?> findMailboxByRole(
String? accountId,
String? role,
) =>
@@ -185,18 +195,46 @@ class MockMailboxRepository extends _i1.Mock implements _i7.MailboxRepository {
role,
],
),
returnValue: _i4.Future<_i8.Mailbox?>.value(),
) as _i4.Future<_i8.Mailbox?>);
returnValue: _i5.Future<_i2.Mailbox?>.value(),
) as _i5.Future<_i2.Mailbox?>);
@override
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
_i5.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
Invocation.method(
#clearForResync,
[accountId],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i5.Future<_i2.Mailbox> createMailboxWithRole(
String? accountId,
String? name,
String? role,
) =>
(super.noSuchMethod(
Invocation.method(
#createMailboxWithRole,
[
accountId,
name,
role,
],
),
returnValue: _i5.Future<_i2.Mailbox>.value(_FakeMailbox_0(
this,
Invocation.method(
#createMailboxWithRole,
[
accountId,
name,
role,
],
),
)),
) as _i5.Future<_i2.Mailbox>);
}
/// A class which mocks [EmailRepository].
@@ -208,13 +246,13 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
}
@override
_i4.Stream<String> get onChangesQueued => (super.noSuchMethod(
_i5.Stream<String> get onChangesQueued => (super.noSuchMethod(
Invocation.getter(#onChangesQueued),
returnValue: _i4.Stream<String>.empty(),
) as _i4.Stream<String>);
returnValue: _i5.Stream<String>.empty(),
) as _i5.Stream<String>);
@override
_i4.Stream<List<_i2.Email>> observeEmails(
_i5.Stream<List<_i3.Email>> observeEmails(
String? accountId,
String? mailboxPath, {
int? limit = 50,
@@ -228,11 +266,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
],
{#limit: limit},
),
returnValue: _i4.Stream<List<_i2.Email>>.empty(),
) as _i4.Stream<List<_i2.Email>>);
returnValue: _i5.Stream<List<_i3.Email>>.empty(),
) as _i5.Stream<List<_i3.Email>>);
@override
_i4.Stream<List<_i2.EmailThread>> observeThreads(
_i5.Stream<List<_i3.EmailThread>> observeThreads(
String? accountId,
String? mailboxPath, {
int? limit = 50,
@@ -246,11 +284,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
],
{#limit: limit},
),
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(),
) as _i4.Stream<List<_i2.EmailThread>>);
returnValue: _i5.Stream<List<_i3.EmailThread>>.empty(),
) as _i5.Stream<List<_i3.EmailThread>>);
@override
_i4.Stream<List<_i2.Email>> observeEmailsInThread(
_i5.Stream<List<_i3.Email>> observeEmailsInThread(
String? accountId,
String? mailboxPath,
String? threadId,
@@ -264,36 +302,36 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
threadId,
],
),
returnValue: _i4.Stream<List<_i2.Email>>.empty(),
) as _i4.Stream<List<_i2.Email>>);
returnValue: _i5.Stream<List<_i3.Email>>.empty(),
) as _i5.Stream<List<_i3.Email>>);
@override
_i4.Future<_i2.Email?> getEmail(String? emailId) => (super.noSuchMethod(
_i5.Future<_i3.Email?> getEmail(String? emailId) => (super.noSuchMethod(
Invocation.method(
#getEmail,
[emailId],
),
returnValue: _i4.Future<_i2.Email?>.value(),
) as _i4.Future<_i2.Email?>);
returnValue: _i5.Future<_i3.Email?>.value(),
) as _i5.Future<_i3.Email?>);
@override
_i4.Future<_i2.EmailBody> getEmailBody(String? emailId) =>
_i5.Future<_i3.EmailBody> getEmailBody(String? emailId) =>
(super.noSuchMethod(
Invocation.method(
#getEmailBody,
[emailId],
),
returnValue: _i4.Future<_i2.EmailBody>.value(_FakeEmailBody_0(
returnValue: _i5.Future<_i3.EmailBody>.value(_FakeEmailBody_1(
this,
Invocation.method(
#getEmailBody,
[emailId],
),
)),
) as _i4.Future<_i2.EmailBody>);
) as _i5.Future<_i3.EmailBody>);
@override
_i4.Future<_i2.SyncEmailsResult> syncEmails(
_i5.Future<_i3.SyncEmailsResult> syncEmails(
String? accountId,
String? mailboxPath,
) =>
@@ -306,7 +344,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
],
),
returnValue:
_i4.Future<_i2.SyncEmailsResult>.value(_FakeSyncEmailsResult_1(
_i5.Future<_i3.SyncEmailsResult>.value(_FakeSyncEmailsResult_2(
this,
Invocation.method(
#syncEmails,
@@ -316,10 +354,10 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
],
),
)),
) as _i4.Future<_i2.SyncEmailsResult>);
) as _i5.Future<_i3.SyncEmailsResult>);
@override
_i4.Future<void> setFlag(
_i5.Future<void> setFlag(
String? emailId, {
bool? seen,
bool? flagged,
@@ -333,12 +371,12 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
#flagged: flagged,
},
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i4.Future<void> markAllAsRead(
_i5.Future<void> markAllAsRead(
String? accountId,
String? mailboxPath,
) =>
@@ -350,12 +388,12 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
mailboxPath,
],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i4.Future<void> moveEmail(
_i5.Future<void> moveEmail(
String? emailId,
String? destMailboxPath,
) =>
@@ -367,23 +405,23 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
destMailboxPath,
],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i4.Future<String?> deleteEmail(String? emailId) => (super.noSuchMethod(
_i5.Future<String?> deleteEmail(String? emailId) => (super.noSuchMethod(
Invocation.method(
#deleteEmail,
[emailId],
),
returnValue: _i4.Future<String?>.value(),
) as _i4.Future<String?>);
returnValue: _i5.Future<String?>.value(),
) as _i5.Future<String?>);
@override
_i4.Future<void> sendEmail(
_i5.Future<void> sendEmail(
String? accountId,
_i2.EmailDraft? draft,
_i3.EmailDraft? draft,
) =>
(super.noSuchMethod(
Invocation.method(
@@ -393,14 +431,14 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
draft,
],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i4.Future<String> downloadAttachment(
_i5.Future<String> downloadAttachment(
String? emailId,
_i2.EmailAttachment? attachment,
_i3.EmailAttachment? attachment,
) =>
(super.noSuchMethod(
Invocation.method(
@@ -410,7 +448,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
attachment,
],
),
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
this,
Invocation.method(
#downloadAttachment,
@@ -420,25 +458,25 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
],
),
)),
) as _i4.Future<String>);
) as _i5.Future<String>);
@override
_i4.Future<String> fetchRawRfc822(String? emailId) => (super.noSuchMethod(
_i5.Future<String> fetchRawRfc822(String? emailId) => (super.noSuchMethod(
Invocation.method(
#fetchRawRfc822,
[emailId],
),
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
this,
Invocation.method(
#fetchRawRfc822,
[emailId],
),
)),
) as _i4.Future<String>);
) as _i5.Future<String>);
@override
_i4.Future<List<_i2.Email>> searchEmails(
_i5.Future<List<_i3.Email>> searchEmails(
String? accountId,
String? mailboxPath,
String? query,
@@ -452,11 +490,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
query,
],
),
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
) as _i4.Future<List<_i2.Email>>);
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
) as _i5.Future<List<_i3.Email>>);
@override
_i4.Future<List<_i2.Email>> searchEmailsGlobal(
_i5.Future<List<_i3.Email>> searchEmailsGlobal(
String? accountId,
String? query,
) =>
@@ -468,11 +506,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
query,
],
),
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
) as _i4.Future<List<_i2.Email>>);
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
) as _i5.Future<List<_i3.Email>>);
@override
_i4.Future<List<_i2.Email>> getEmailsByAddress(
_i5.Future<List<_i3.Email>> getEmailsByAddress(
String? accountId,
String? address,
) =>
@@ -484,11 +522,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
address,
],
),
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
) as _i4.Future<List<_i2.Email>>);
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
) as _i5.Future<List<_i3.Email>>);
@override
_i4.Future<List<_i2.EmailAddress>> searchAddresses(
_i5.Future<List<_i3.EmailAddress>> searchAddresses(
String? accountId,
String? query, {
int? limit = 10,
@@ -503,11 +541,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
{#limit: limit},
),
returnValue:
_i4.Future<List<_i2.EmailAddress>>.value(<_i2.EmailAddress>[]),
) as _i4.Future<List<_i2.EmailAddress>>);
_i5.Future<List<_i3.EmailAddress>>.value(<_i3.EmailAddress>[]),
) as _i5.Future<List<_i3.EmailAddress>>);
@override
_i4.Future<int> flushPendingChanges(
_i5.Future<int> flushPendingChanges(
String? accountId,
String? password,
) =>
@@ -519,42 +557,42 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
password,
],
),
returnValue: _i4.Future<int>.value(0),
) as _i4.Future<int>);
returnValue: _i5.Future<int>.value(0),
) as _i5.Future<int>);
@override
_i4.Stream<List<_i2.FailedMutation>> observeFailedMutations(
_i5.Stream<List<_i3.FailedMutation>> observeFailedMutations(
String? accountId) =>
(super.noSuchMethod(
Invocation.method(
#observeFailedMutations,
[accountId],
),
returnValue: _i4.Stream<List<_i2.FailedMutation>>.empty(),
) as _i4.Stream<List<_i2.FailedMutation>>);
returnValue: _i5.Stream<List<_i3.FailedMutation>>.empty(),
) as _i5.Stream<List<_i3.FailedMutation>>);
@override
_i4.Future<void> discardMutation(int? id) => (super.noSuchMethod(
_i5.Future<void> discardMutation(int? id) => (super.noSuchMethod(
Invocation.method(
#discardMutation,
[id],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i4.Future<void> retryMutation(int? id) => (super.noSuchMethod(
_i5.Future<void> retryMutation(int? id) => (super.noSuchMethod(
Invocation.method(
#retryMutation,
[id],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i4.Future<bool> cancelPendingChange(
_i5.Future<bool> cancelPendingChange(
String? emailId,
String? changeType,
) =>
@@ -566,11 +604,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
changeType,
],
),
returnValue: _i4.Future<bool>.value(false),
) as _i4.Future<bool>);
returnValue: _i5.Future<bool>.value(false),
) as _i5.Future<bool>);
@override
_i4.Future<void> snoozeEmail(
_i5.Future<void> snoozeEmail(
String? emailId,
DateTime? until,
) =>
@@ -582,32 +620,32 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
until,
],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i4.Future<int> wakeUpEmails(String? accountId) => (super.noSuchMethod(
_i5.Future<int> wakeUpEmails(String? accountId) => (super.noSuchMethod(
Invocation.method(
#wakeUpEmails,
[accountId],
),
returnValue: _i4.Future<int>.value(0),
) as _i4.Future<int>);
returnValue: _i5.Future<int>.value(0),
) as _i5.Future<int>);
@override
_i4.Future<void> restoreEmails(List<_i2.Email>? emails) =>
_i5.Future<void> restoreEmails(List<_i3.Email>? emails) =>
(super.noSuchMethod(
Invocation.method(
#restoreEmails,
[emails],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i4.Future<_i2.Email?> findEmailByMessageId(
_i5.Future<_i3.Email?> findEmailByMessageId(
String? accountId,
String? messageId,
) =>
@@ -619,20 +657,20 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
messageId,
],
),
returnValue: _i4.Future<_i2.Email?>.value(),
) as _i4.Future<_i2.Email?>);
returnValue: _i5.Future<_i3.Email?>.value(),
) as _i5.Future<_i3.Email?>);
@override
_i4.Future<int> applySieveRules(String? accountId) => (super.noSuchMethod(
_i5.Future<int> applySieveRules(String? accountId) => (super.noSuchMethod(
Invocation.method(
#applySieveRules,
[accountId],
),
returnValue: _i4.Future<int>.value(0),
) as _i4.Future<int>);
returnValue: _i5.Future<int>.value(0),
) as _i5.Future<int>);
@override
_i4.Stream<void> watchJmapPush(
_i5.Stream<void> watchJmapPush(
String? accountId,
String? password,
) =>
@@ -644,11 +682,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
password,
],
),
returnValue: _i4.Stream<void>.empty(),
) as _i4.Stream<void>);
returnValue: _i5.Stream<void>.empty(),
) as _i5.Stream<void>);
@override
_i4.Future<_i2.ReliabilityResult> verifySyncReliability(
_i5.Future<_i3.ReliabilityResult> verifySyncReliability(
String? accountId,
String? mailboxPath,
) =>
@@ -661,7 +699,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
],
),
returnValue:
_i4.Future<_i2.ReliabilityResult>.value(_FakeReliabilityResult_2(
_i5.Future<_i3.ReliabilityResult>.value(_FakeReliabilityResult_3(
this,
Invocation.method(
#verifySyncReliability,
@@ -671,15 +709,15 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
],
),
)),
) as _i4.Future<_i2.ReliabilityResult>);
) as _i5.Future<_i3.ReliabilityResult>);
@override
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
_i5.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
Invocation.method(
#clearForResync,
[accountId],
),
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
}
+173
View File
@@ -14,6 +14,7 @@ import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
import 'account_repository_impl_test.dart' show MapSecureStorage;
import 'db_test_helper.dart';
import 'fake_imap.dart' show SnoozeSpyImapClient;
// ── Helpers ───────────────────────────────────────────────────────────────────
const _account = Account(
@@ -432,5 +433,177 @@ void main() {
expect(result, isNotNull);
expect(result!.role, 'inbox');
});
group('createMailboxWithRole', () {
test('IMAP: creates mailbox on server and persists with role', () async {
final spy = SnoozeSpyImapClient();
final db = openTestDatabase();
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
final mailboxes = MailboxRepositoryImpl(
db,
accounts,
imapConnect: (_, __, ___) async => spy,
);
await accounts.addAccount(_account, 'pw');
final result = await mailboxes.createMailboxWithRole(
'acc-1',
'Archive',
'archive',
);
expect(spy.createdMailbox, 'Archive');
expect(result.name, 'Archive');
expect(result.role, 'archive');
expect(result.path, 'Archive');
final found = await mailboxes.findMailboxByRole('acc-1', 'archive');
expect(found, isNotNull);
expect(found!.name, 'Archive');
});
test('JMAP: creates mailbox on server and persists with role', () async {
final r = _makeRepos(
httpClient: _mockJmap(
apiResponses: [
{
'sessionState': 'sess1',
'methodResponses': [
[
'Mailbox/set',
{
'accountId': 'acct1',
'created': {
'new-mailbox': {'id': 'mbx-archive'},
},
},
'0',
],
],
},
],
),
);
await r.accounts.addAccount(_jmapAccount, 'pw');
final result = await r.mailboxes
.createMailboxWithRole('jmap-1', 'Archive', 'archive');
expect(result.name, 'Archive');
expect(result.role, 'archive');
expect(result.path, 'mbx-archive');
final found = await r.mailboxes.findMailboxByRole('jmap-1', 'archive');
expect(found, isNotNull);
expect(found!.name, 'Archive');
});
test(
'JMAP: throws when server returns no created ID',
() async {
final r = _makeRepos(
httpClient: _mockJmap(
apiResponses: [
{
'sessionState': 'sess1',
'methodResponses': [
[
'Mailbox/set',
{
'accountId': 'acct1',
'created': null,
'notCreated': {
'new-mailbox': {'type': 'serverFail'},
},
},
'0',
],
],
},
],
),
);
await r.accounts.addAccount(_jmapAccount, 'pw');
await expectLater(
r.mailboxes.createMailboxWithRole('jmap-1', 'Archive', 'archive'),
throwsA(isA<Exception>()),
);
},
);
});
group('syncMailboxes IMAP preserves manually-set role', () {
test('existing role is kept when server returns no special-use flag',
() async {
final spy = SnoozeSpyImapClient();
// Make listMailboxes return a plain folder without \Archive.
final db = openTestDatabase();
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
// Override listMailboxes to return one plain folder.
final fakeClient = _PlainArchiveImapClient();
final mailboxes = MailboxRepositoryImpl(
db,
accounts,
imapConnect: (_, __, ___) async => fakeClient,
);
await accounts.addAccount(_account, 'pw');
// Pre-seed the DB with role='archive' (as if user created the folder).
await db.into(db.mailboxes).insert(
MailboxesCompanion.insert(
id: 'acc-1:Archive',
accountId: 'acc-1',
path: 'Archive',
name: 'Archive',
role: const Value('archive'),
),
);
await mailboxes.syncMailboxes('acc-1');
final found = await mailboxes.findMailboxByRole('acc-1', 'archive');
expect(
found,
isNotNull,
reason: 'Manually-set role should be preserved after sync',
);
expect(found!.path, 'Archive');
// Suppress unused warning on spy.
expect(spy, isNotNull);
});
});
});
}
/// Fake IMAP client that lists one mailbox named 'Archive' without any
/// special-use flags, and logs out cleanly.
class _PlainArchiveImapClient extends SnoozeSpyImapClient {
@override
Future<List<imap.Mailbox>> listMailboxes({
String path = '""',
bool recursive = false,
List<String>? mailboxPatterns,
List<String>? selectionOptions,
List<imap.ReturnOption>? returnOptions,
}) async =>
[
imap.Mailbox(
encodedName: 'Archive',
encodedPath: 'Archive',
pathSeparator: '/',
flags: [], // No \Archive special-use flag
),
];
@override
Future<imap.Mailbox> statusMailbox(
imap.Mailbox mailbox,
List<imap.StatusFlags> flags,
) async =>
mailbox;
@override
Future<dynamic> logout() async {}
}
@@ -62,6 +62,21 @@ class _FakeMailboxes implements MailboxRepository {
null;
@override
Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async =>
Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
}
class _FakeEmails implements EmailRepository {
+15
View File
@@ -54,6 +54,21 @@ class _FakeMailboxes implements MailboxRepository {
null;
@override
Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async =>
Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
}
class _CountingEmails implements EmailRepository {
+146
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
import 'package:sharedinbox/ui/screens/email_list_screen.dart';
@@ -631,5 +632,150 @@ void main() {
expect(find.text('This is the preview text'), findsOneWidget);
});
group('archive with missing folder', () {
testWidgets('shows dialog when archive folder is not found', (
tester,
) async {
final email = testEmail(subject: 'To archive');
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
// No archive folder in the repo.
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
],
),
);
await tester.pumpAndSettle();
// Enter selection mode and tap archive.
await tester.longPress(find.text('To archive'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.archive));
await tester.pumpAndSettle();
expect(find.text('No archive folder found'), findsOneWidget);
expect(find.text('Choose existing folder'), findsOneWidget);
expect(find.text('Create "Archive"'), findsOneWidget);
});
testWidgets('tapping Create creates the folder and moves emails', (
tester,
) async {
final email = testEmail(subject: 'To archive');
final movedTo = <String>[];
final fakeEmailRepo = _SpyEmailRepository(
emails: [email],
onMove: (id, path) => movedTo.add(path),
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(fakeEmailRepo),
],
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('To archive'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.archive));
await tester.pumpAndSettle();
// Tap "Create Archive".
await tester.tap(find.text('Create "Archive"'));
await tester.pumpAndSettle();
expect(movedTo, contains('Archive'));
});
testWidgets(
'tapping Choose existing opens folder picker and moves emails',
(tester) async {
final email = testEmail(subject: 'To archive');
final movedTo = <String>[];
final fakeEmailRepo = _SpyEmailRepository(
emails: [email],
onMove: (id, path) => movedTo.add(path),
);
const archiveFolder = Mailbox(
id: 'acc-1:OldArchive',
accountId: 'acc-1',
path: 'OldArchive',
name: 'OldArchive',
unreadCount: 0,
totalCount: 0,
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
// Repo has a folder but it has no 'archive' role.
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository([archiveFolder]),
),
emailRepositoryProvider.overrideWithValue(fakeEmailRepo),
],
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('To archive'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.archive));
await tester.pumpAndSettle();
// Tap "Choose existing folder".
await tester.tap(find.text('Choose existing folder'));
await tester.pumpAndSettle();
// Bottom sheet with folder list appears.
expect(find.text('OldArchive'), findsOneWidget);
await tester.tap(find.text('OldArchive'));
await tester.pumpAndSettle();
expect(movedTo, contains('OldArchive'));
},
);
});
});
}
/// Email repository spy that records [moveEmail] calls.
class _SpyEmailRepository extends FakeEmailRepository {
_SpyEmailRepository({
super.emails,
required void Function(String emailId, String path) onMove,
}) : _onMove = onMove;
final void Function(String emailId, String path) _onMove;
@override
Future<void> moveEmail(String emailId, String destMailboxPath) async {
_onMove(emailId, destMailboxPath);
}
}
+20
View File
@@ -164,8 +164,28 @@ class FakeMailboxRepository implements MailboxRepository {
@override
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
_mailboxes.where((m) => m.role == role).firstOrNull;
@override
Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async {
final mailbox = Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
_mailboxes.add(mailbox);
return mailbox;
}
}
class FakeEmailRepository implements EmailRepository {
+2 -1
View File
@@ -25,7 +25,8 @@ The app processes the following data **exclusively on your device**:
device's secure storage and never transmitted to us.
- **Email messages and attachments** — fetched directly from your email provider's IMAP server and
displayed in the app. We never receive, store, or process your emails.
- **App settings and configuration** — stored locally on your device.
- **App settings and configuration** — stored locally on your device. The app will never upload
this data to sharedinbox.de or any third-party service.
### Network connections