@remit/mcp-server 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/deps.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import type { RemitClient } from "@remit/backend/client";
2
- import type { IAccountRepository, IAddressRepository, IFilterRepository, ILabelMailboxRepository, ILabelRepository, IMailboxRepository, IMessageLabelRepository, IMessageRepository, IOrganizeJobRequestRepository, IThreadMessageRepository, OrganizeJobEvent, QueueProducer } from "@remit/data-ports";
2
+ import type { IAccountRepository, IAccountSettingRepository, IAddressRepository, IFilterRepository, ILabelMailboxRepository, ILabelRepository, IMailboxRepository, IMailboxSpecialUseRepository, IMessageLabelRepository, IMessageRepository, IOrganizeJobRequestRepository, IThreadMessageRepository, OrganizeJobEvent, QueueProducer } from "@remit/data-ports";
3
3
  import type { BodyPartMaterializer, BodySyncCue, MailboxQueueService, MaterializeLogger, MessageMoveService, OrganizeJobLogger } from "@remit/mailbox-service";
4
4
  import type { StorageService } from "@remit/storage-service";
5
5
  export declare const FOLDER_SETTLE_TIMEOUT_MS = 20000;
6
6
  export declare const FOLDER_POLL_INTERVAL_MS = 500;
7
7
  export interface FolderToolDeps {
8
- mailboxQueue: Pick<MailboxQueueService, "createMailbox" | "renameMailbox" | "dismissMailboxIntent">;
8
+ mailboxQueue: Pick<MailboxQueueService, "createMailbox" | "renameMailbox" | "dismissMailboxIntent" | "deleteMailbox">;
9
9
  settleTimeoutMs: number;
10
10
  pollIntervalMs: number;
11
11
  sleep: (ms: number) => Promise<void>;
@@ -16,14 +16,16 @@ export interface FilterToolDeps {
16
16
  }
17
17
  export interface McpDeps {
18
18
  account: Pick<IAccountRepository, "get" | "listAllByAccountConfig" | "listMailSourcesByAccountConfig">;
19
- mailbox: Pick<IMailboxRepository, "get" | "listAllByAccount" | "findByPath" | "resolveAccountId">;
19
+ mailbox: Pick<IMailboxRepository, "get" | "listAllByAccount" | "findByPath" | "findByPathPrefix" | "resolveAccountId">;
20
+ accountSetting: Pick<IAccountSettingRepository, "get">;
21
+ mailboxSpecialUse: Pick<IMailboxSpecialUseRepository, "resolveTrashRole">;
20
22
  address: Pick<IAddressRepository, "countCategoryOverridesSince">;
21
23
  label: Pick<ILabelRepository, "create" | "findByNormalizedName" | "get" | "listByAccountConfig">;
22
24
  labelMailbox: Pick<ILabelMailboxRepository, "get" | "put" | "listByAccount" | "listByLabel">;
23
- message: Pick<IMessageRepository, "get" | "describe" | "countClassifiedSince">;
25
+ message: Pick<IMessageRepository, "get" | "describe" | "countClassifiedSince" | "listAllByMailbox" | "listByMailbox" | "listStrandedPlacements">;
24
26
  messageLabel: Pick<IMessageLabelRepository, "apply">;
25
27
  messageMove: Pick<MessageMoveService, "moveMessages">;
26
- threadMessage: Pick<IThreadMessageRepository, "listByMailbox" | "listByThread" | "searchByDate">;
28
+ threadMessage: Pick<IThreadMessageRepository, "listByThread" | "searchByDate">;
27
29
  filter: Pick<IFilterRepository, "listByAccountConfig" | "refreshExpiry" | "create" | "get" | "update">;
28
30
  organizeJobRequest: Pick<IOrganizeJobRequestRepository, "create" | "get">;
29
31
  storage: Pick<StorageService, "retrieveBodyPart">;
package/dist/deps.js CHANGED
@@ -3,6 +3,8 @@ export const FOLDER_POLL_INTERVAL_MS = 500;
3
3
  export const mcpDepsOf = (client, { logger, settle }) => ({
4
4
  account: client.account,
5
5
  mailbox: client.mailbox,
6
+ accountSetting: client.accountSetting,
7
+ mailboxSpecialUse: client.mailboxSpecialUse,
6
8
  address: client.address,
7
9
  label: client.label,
8
10
  labelMailbox: client.labelMailbox,
@@ -1,8 +1,12 @@
1
+ import { deletableFolder, deleteFolder } from "@remit/backend/folder-delete";
1
2
  import { isNotFoundError } from "@remit/data-ports/errors";
3
+ import { isReservedFolderName } from "@remit/data-ports/folder-role";
4
+ import { mailboxLeafName } from "@remit/data-ports/mailbox-name";
2
5
  import { MailboxSyncStatus } from "@remit/domain-enums";
3
- import { changeFolderPath, createFolder, settledFolder, } from "@remit/mailbox-service";
6
+ import { changeFolderPath, createFolder, invalid, isRemitRootName, ownedFolder, settledFolder, } from "@remit/mailbox-service";
4
7
  import { z } from "zod";
5
- import { asJson, refused, toolError } from "./tool-result.js";
8
+ import { asJson, notFoundRefusal, refused, toolError } from "./tool-result.js";
9
+ import { trashFolderOf } from "./trash.js";
6
10
  const CREATE_DELIMITER = "/";
7
11
  const FOLDER_WRITE = {
8
12
  readOnlyHint: false,
@@ -10,7 +14,7 @@ const FOLDER_WRITE = {
10
14
  idempotentHint: true,
11
15
  openWorldHint: false,
12
16
  };
13
- const awaitSettled = async (deps, folders, accountId, mailboxId) => {
17
+ const awaitSettled = async (deps, folders, accountId, mailboxId, inFlight = MailboxSyncStatus.pending) => {
14
18
  const deadline = Date.now() + folders.settleTimeoutMs;
15
19
  for (;;) {
16
20
  const folder = await deps.mailbox
@@ -22,8 +26,7 @@ const awaitSettled = async (deps, folders, accountId, mailboxId) => {
22
26
  });
23
27
  if (!folder)
24
28
  return { state: "Gone" };
25
- if (folder.syncStatus !== MailboxSyncStatus.pending ||
26
- Date.now() >= deadline) {
29
+ if (folder.syncStatus !== inFlight || Date.now() >= deadline) {
27
30
  return { state: "Present", folder };
28
31
  }
29
32
  await folders.sleep(folders.pollIntervalMs);
@@ -78,6 +81,81 @@ const renameOutcome = (watched, requested) => {
78
81
  return settledOrRefused(folder);
79
82
  }
80
83
  };
84
+ const deleteOutcome = (watched) => {
85
+ if (watched.state === "Gone")
86
+ return asJson({ deleted: true });
87
+ const { folder } = watched;
88
+ switch (folder.syncStatus) {
89
+ case MailboxSyncStatus.deleting:
90
+ return asJson({
91
+ ...folderState(folder),
92
+ message: "The mail server has not confirmed this yet. Call delete_folder again with the same arguments to keep waiting; it will not delete twice.",
93
+ });
94
+ case MailboxSyncStatus.failed:
95
+ return toolError(`The mail server refused to delete “${folder.fullPath}”: ${folder.syncFailureReason || "no reason given"}. Call delete_folder again to retry.`);
96
+ default:
97
+ return toolError(`The delete of “${folder.fullPath}” was dropped before the mail server confirmed it. Call list_mailboxes to see what is there now.`);
98
+ }
99
+ };
100
+ const notFound = notFoundRefusal("");
101
+ // Folders the mail app, the mail server or Remit itself depends on, on top of
102
+ // what the shared delete refuses: INBOX, folders with subfolders and folders a
103
+ // filter or role points at.
104
+ const keptFolderReason = async (deps, accountConfigId, folder) => {
105
+ const leaf = mailboxLeafName(folder);
106
+ const specialUse = folder.specialUse ?? [];
107
+ if (specialUse.length > 0) {
108
+ return `“${folder.fullPath}” is the account's ${specialUse.join(" and ")} folder, which the mail app relies on.`;
109
+ }
110
+ if (isReservedFolderName(leaf)) {
111
+ return `“${folder.fullPath}” carries a system folder name the mail app relies on.`;
112
+ }
113
+ if (isRemitRootName(leaf)) {
114
+ return `“${folder.fullPath}” holds Remit's label folders.`;
115
+ }
116
+ const mapping = (await deps.labelMailbox.listByAccount(folder.accountId)).find((row) => row.mailboxId === folder.mailboxId);
117
+ if (mapping) {
118
+ const label = await deps.label.get(accountConfigId, mapping.labelId);
119
+ return `“${folder.fullPath}” is the folder of the label “${label.name}”. Delete the label instead.`;
120
+ }
121
+ return undefined;
122
+ };
123
+ const movingOut = async (deps, mailboxId) => (await deps.message.listStrandedPlacements([mailboxId], Date.now() + 1)).filter((row) => row.originalMailboxId === mailboxId).length;
124
+ // Moves out of the folder settle before it is deleted: the server must hold
125
+ // that mail in Trash before DELETE removes the folder (imap-mutations R2: wait).
126
+ const awaitMovedOut = async (deps, mailboxId) => {
127
+ const { folders } = deps;
128
+ const deadline = Date.now() + folders.settleTimeoutMs;
129
+ for (;;) {
130
+ const pending = await movingOut(deps, mailboxId);
131
+ if (pending === 0 || Date.now() >= deadline)
132
+ return pending;
133
+ await folders.sleep(folders.pollIntervalMs);
134
+ }
135
+ };
136
+ // Force never loses mail: it moves what the folder holds to Trash and waits
137
+ // for the server to confirm, and only then does the delete go ahead.
138
+ const trashFolderMail = async (deps, accountConfigId, folder) => {
139
+ const trash = await trashFolderOf(deps, folder.accountId);
140
+ if (!trash.ok)
141
+ return refused(trash.refusal);
142
+ const held = await deps.message.listAllByMailbox(folder.mailboxId);
143
+ if (held.length > 0) {
144
+ await deps.messageMove.moveMessages(accountConfigId, held.map((row) => row.messageId), trash.value, folder.accountId);
145
+ }
146
+ const pending = await awaitMovedOut(deps, folder.mailboxId);
147
+ if (pending > 0) {
148
+ return asJson({
149
+ ...folderState(folder),
150
+ message: `${pending} ${pending === 1 ? "message is" : "messages are"} still moving to Trash. Call delete_folder again with the same arguments to keep waiting; the folder is deleted once they have arrived.`,
151
+ });
152
+ }
153
+ const left = await deps.message.listByMailbox(folder.mailboxId, { limit: 1 });
154
+ if (left.items.length > 0) {
155
+ return toolError(`Not all mail in “${folder.fullPath}” could be moved to Trash, so the folder was kept. Call delete_folder again to retry.`);
156
+ }
157
+ return undefined;
158
+ };
81
159
  export const registerFolderTools = (server, deps, accountConfigId) => {
82
160
  server.registerTool("create_folder", {
83
161
  description: "Create a folder in one email account and wait until the mail server confirms it. Returns the confirmed path, or the pending state if the server is slow; calling again with the same arguments resumes the wait.",
@@ -122,4 +200,51 @@ export const registerFolderTools = (server, deps, accountConfigId) => {
122
200
  return refused(changed.refusal);
123
201
  return renameOutcome(await awaitSettled(deps, folders, accountId, mailboxId), fullPath);
124
202
  });
203
+ server.registerTool("delete_folder", {
204
+ description: "Delete an empty folder on the mail server and in Remit, and wait until the server confirms it. Refuses a folder that still holds messages unless force is true, a folder with subfolders (delete those first), system folders, Remit's label folders and folders a filter or folder role uses. With force, the folder's mail first moves to Trash, and the folder is deleted once the server confirms the moves. Calling again with the same arguments resumes the wait.",
205
+ inputSchema: {
206
+ accountId: z.string().min(1).describe("Account from list_mailboxes"),
207
+ mailboxId: z.string().min(1).describe("Folder from list_mailboxes"),
208
+ force: z
209
+ .boolean()
210
+ .optional()
211
+ .describe("Move the folder's messages to Trash first, then delete the folder"),
212
+ },
213
+ annotations: { ...FOLDER_WRITE, destructiveHint: true },
214
+ }, async ({ accountId, mailboxId, force }) => {
215
+ const { folders } = deps;
216
+ const owned = await ownedFolder(deps, accountConfigId, accountId, mailboxId).catch(notFound);
217
+ if (!owned.ok)
218
+ return refused(owned.refusal);
219
+ const folder = owned.value;
220
+ if (folder.syncStatus !== MailboxSyncStatus.deleting) {
221
+ const deleteDeps = { ...deps, mailboxQueue: folders.mailboxQueue };
222
+ const deletable = await deletableFolder(deleteDeps, accountConfigId, {
223
+ accountId,
224
+ mailboxId,
225
+ });
226
+ if (!deletable.ok)
227
+ return refused(deletable.refusal);
228
+ const kept = await keptFolderReason(deps, accountConfigId, folder);
229
+ if (kept)
230
+ return refused(invalid(kept).refusal);
231
+ const count = folder.messageCount;
232
+ if (!force && count > 0) {
233
+ return toolError(`“${folder.fullPath}” still holds ${count} ${count === 1 ? "message" : "messages"}. Move them out first, for example with move_folder_messages, and call delete_folder again once the moves have settled; or pass force: true to move them to Trash and delete the folder.`);
234
+ }
235
+ if (force) {
236
+ const notYet = await trashFolderMail(deps, accountConfigId, folder);
237
+ if (notYet)
238
+ return notYet;
239
+ }
240
+ const deleted = await deleteFolder(deleteDeps, accountConfigId, {
241
+ accountId,
242
+ mailboxId,
243
+ requireEmpty: true,
244
+ });
245
+ if (!deleted.ok)
246
+ return refused(deleted.refusal);
247
+ }
248
+ return deleteOutcome(await awaitSettled(deps, folders, accountId, mailboxId, MailboxSyncStatus.deleting));
249
+ });
125
250
  };
@@ -2,9 +2,10 @@ import { ORGANIZE_MATCH_LIMIT, organizePredicateRejection, } from "@remit/backen
2
2
  import { FILTER_NO_ACTION, FilterRuleV1Schema, filterRuleFromClauses, filterRuleLimitRefusal, } from "@remit/data-ports";
3
3
  import { deriveAccountConfigId } from "@remit/data-ports/id";
4
4
  import { FilterMatchOperator } from "@remit/domain-enums";
5
- import { accept, folderInAccount, invalid, moveMessagesTo, ownedAccount, queueOrganizeJob, resolveMoveDestination, } from "@remit/mailbox-service";
5
+ import { accept, folderInAccount, invalid, labelFoldersFor, moveMessagesTo, ownedAccount, ownedMessagesByAccount, queueOrganizeJob, resolveMoveDestination, sentDateRange, } from "@remit/mailbox-service";
6
6
  import { z } from "zod";
7
7
  import { asJson, notFoundRefusal, refused } from "./tool-result.js";
8
+ import { trashFolderOf } from "./trash.js";
8
9
  const MAX_MOVE_MESSAGES = 500;
9
10
  const MOVE_WRITE = {
10
11
  readOnlyHint: false,
@@ -37,6 +38,14 @@ const oneTarget = (args) => {
37
38
  return accept({ labelId: args.labelId });
38
39
  return invalid("Send a target: mailboxId for a folder, or labelId.");
39
40
  };
41
+ const oneBulkTarget = (args) => {
42
+ if (!args.trash)
43
+ return oneTarget(args);
44
+ if (args.mailboxId || args.labelId) {
45
+ return invalid("Send trash, mailboxId or labelId, not more than one.");
46
+ }
47
+ return accept({ trash: true });
48
+ };
40
49
  const jobAction = (destination) => ({
41
50
  actionMailboxId: destination.mailboxId,
42
51
  actionLabelId: destination.kind === "Label" ? destination.labelId : FILTER_NO_ACTION,
@@ -86,16 +95,137 @@ export const registerMoveTools = (server, deps, userId) => {
86
95
  destinations: moved.value.map(({ messageIds: _, ...group }) => group),
87
96
  });
88
97
  });
98
+ const sourcesOf = async (args) => {
99
+ if (args.sourceMailboxId && args.sourceLabelId) {
100
+ return invalid("Send sourceMailboxId or sourceLabelId, not both.");
101
+ }
102
+ if (args.sourceLabelId) {
103
+ const label = await deps.label.get(accountConfigId, args.sourceLabelId);
104
+ const [found] = await labelFoldersFor(deps, accountConfigId, [label]);
105
+ const folders = found?.folders ?? [];
106
+ const present = folders.flatMap((folder) => folder.state === "Present"
107
+ ? [{ accountId: folder.accountId, mailboxId: folder.mailboxId }]
108
+ : []);
109
+ if (present.length === 0) {
110
+ return invalid(`Label “${label.name}” has no confirmed folder yet.`);
111
+ }
112
+ return accept({
113
+ present,
114
+ skipped: folders.flatMap((folder) => folder.state === "Present"
115
+ ? []
116
+ : [{ accountId: folder.accountId, folderState: folder.state }]),
117
+ });
118
+ }
119
+ if (!args.sourceMailboxId || !args.accountId) {
120
+ return invalid("Send a source: accountId with sourceMailboxId for a folder, or sourceLabelId.");
121
+ }
122
+ const { accountId, sourceMailboxId } = args;
123
+ const owned = await deps.account
124
+ .get(accountId)
125
+ .then((account) => ownedAccount(account, accountConfigId, "act"));
126
+ if (!owned.ok)
127
+ return owned;
128
+ const source = folderInAccount(await deps.mailbox.get(accountId, sourceMailboxId), accountId, "act");
129
+ if (!source.ok)
130
+ return source;
131
+ return accept({
132
+ present: [{ accountId, mailboxId: sourceMailboxId }],
133
+ skipped: [],
134
+ });
135
+ };
136
+ const actionFor = async (source, target) => {
137
+ if ("trash" in target) {
138
+ const trash = await trashFolderOf(deps, source.accountId);
139
+ if (!trash.ok)
140
+ return trash;
141
+ return accept({
142
+ actionMailboxId: trash.value,
143
+ actionLabelId: FILTER_NO_ACTION,
144
+ });
145
+ }
146
+ const destination = await resolveMoveDestination(deps, accountConfigId, source.accountId, target);
147
+ if (!destination.ok)
148
+ return destination;
149
+ return accept(jobAction(destination.value));
150
+ };
151
+ server.registerTool("trash_messages", {
152
+ description: "Move messages to Trash in their own account. A message already in Trash is left untouched and reported. Returns once the moves are recorded; the mail server confirms them shortly after.",
153
+ inputSchema: {
154
+ messageIds: z
155
+ .array(z.string().min(1))
156
+ .min(1)
157
+ .max(MAX_MOVE_MESSAGES)
158
+ .describe("Messages from list_threads, search_threads or get_thread"),
159
+ },
160
+ annotations: MOVE_WRITE,
161
+ }, async ({ messageIds }) => {
162
+ const ids = [...new Set(messageIds)];
163
+ const owned = await ownedMessagesByAccount(deps, ids, accountConfigId, "act").catch(notFound);
164
+ if (!owned.ok)
165
+ return refused(owned.refusal);
166
+ const planned = [];
167
+ for (const [accountId, accountIds] of owned.value) {
168
+ const trash = await trashFolderOf(deps, accountId);
169
+ if (!trash.ok)
170
+ return refused(trash.refusal);
171
+ planned.push({
172
+ accountId,
173
+ trashMailboxId: trash.value,
174
+ messageIds: accountIds,
175
+ });
176
+ }
177
+ let trashed = 0;
178
+ const alreadyInTrash = [];
179
+ for (const { accountId, trashMailboxId, messageIds: accountIds, } of planned) {
180
+ const rows = await deps.message.get(accountIds);
181
+ const toMove = rows.flatMap((row) => {
182
+ if (row.mailboxId !== trashMailboxId)
183
+ return [row.messageId];
184
+ alreadyInTrash.push(row.messageId);
185
+ return [];
186
+ });
187
+ if (toMove.length === 0)
188
+ continue;
189
+ await deps.messageMove.moveMessages(accountConfigId, toMove, trashMailboxId, accountId);
190
+ trashed += toMove.length;
191
+ }
192
+ return asJson({
193
+ trashed,
194
+ ...(alreadyInTrash.length > 0 ? { alreadyInTrash } : {}),
195
+ });
196
+ });
89
197
  server.registerTool("move_folder_messages", {
90
- description: `Move every message in one folder, or only those matching a filter's rule or a given rule, into another folder or a label's folder. Runs as an organize job: returns its organizeJobId, which get_organize_job reports on. One job moves at most ${ORGANIZE_MATCH_LIMIT} messages; call again while a finished job reports appliedCount above 0.`,
198
+ description: `Move every message in a folder, or in a label's folders, or only those matching a filter's rule or a given rule, and optionally only those dated within before/after, into another folder, a label's folder, or Trash. Runs as organize jobs, one per account: returns their organizeJobIds, which get_organize_job reports on. One job moves at most ${ORGANIZE_MATCH_LIMIT} messages; call again while a finished job reports appliedCount above 0.`,
91
199
  inputSchema: {
92
- accountId: z.string().min(1).describe("Account from list_mailboxes"),
200
+ accountId: z
201
+ .string()
202
+ .min(1)
203
+ .optional()
204
+ .describe("Account from list_mailboxes; required with sourceMailboxId"),
93
205
  sourceMailboxId: z
94
206
  .string()
95
207
  .min(1)
96
- .describe("Folder from list_mailboxes to move messages out of"),
208
+ .optional()
209
+ .describe("Folder from list_mailboxes to move messages out of. Send this or sourceLabelId."),
210
+ sourceLabelId: z
211
+ .string()
212
+ .min(1)
213
+ .optional()
214
+ .describe("Label from list_labels: move messages out of its folder in every account. Send this or sourceMailboxId."),
97
215
  mailboxId: targetMailboxId,
98
216
  labelId: targetLabelId,
217
+ trash: z
218
+ .boolean()
219
+ .optional()
220
+ .describe("true moves the messages to Trash in their own account. Send this, mailboxId or labelId."),
221
+ before: z
222
+ .string()
223
+ .optional()
224
+ .describe("Only messages dated before this, an ISO 8601 date-time with zone offset such as 2026-08-13T00:00:00+02:00"),
225
+ after: z
226
+ .string()
227
+ .optional()
228
+ .describe("Only messages dated at or after this, an ISO 8601 date-time with zone offset"),
99
229
  filterId: z
100
230
  .string()
101
231
  .min(1)
@@ -104,47 +234,70 @@ export const registerMoveTools = (server, deps, userId) => {
104
234
  rule: FilterRuleV1Schema.optional().describe("Only move messages this rule matches, in the shape create_filter takes. Send this or filterId, or neither to move everything."),
105
235
  },
106
236
  annotations: { ...MOVE_WRITE, idempotentHint: false },
107
- }, async ({ accountId, sourceMailboxId, mailboxId, labelId, ...match }) => {
108
- const target = oneTarget({ mailboxId, labelId });
237
+ }, async ({ accountId, sourceMailboxId, sourceLabelId, mailboxId, labelId, trash, before, after, ...match }) => {
238
+ const target = oneBulkTarget({ mailboxId, labelId, trash });
109
239
  if (!target.ok)
110
240
  return refused(target.refusal);
111
- const owned = await deps.account
112
- .get(accountId)
113
- .then((account) => ownedAccount(account, accountConfigId, "act"))
114
- .catch(notFound);
115
- if (!owned.ok)
116
- return refused(owned.refusal);
117
- const source = await deps.mailbox
118
- .get(accountId, sourceMailboxId)
119
- .then((folder) => folderInAccount(folder, accountId, "act"))
120
- .catch(notFound);
121
- if (!source.ok)
122
- return refused(source.refusal);
123
- const destination = await resolveMoveDestination(deps, accountConfigId, accountId, target.value).catch(notFound);
124
- if (!destination.ok)
125
- return refused(destination.refusal);
126
- const action = jobAction(destination.value);
127
- if (action.actionMailboxId === sourceMailboxId) {
128
- return refused(invalid("The target folder is the source folder.").refusal);
241
+ const sent = sentDateRange({ before, after });
242
+ if (!sent.ok)
243
+ return refused(sent.refusal);
244
+ const sources = await sourcesOf({
245
+ accountId,
246
+ sourceMailboxId,
247
+ sourceLabelId,
248
+ }).catch(notFound);
249
+ if (!sources.ok)
250
+ return refused(sources.refusal);
251
+ const planned = [];
252
+ for (const source of sources.value.present) {
253
+ const action = await actionFor(source, target.value).catch(notFound);
254
+ if (!action.ok)
255
+ return refused(action.refusal);
256
+ if (action.value.actionMailboxId === source.mailboxId) {
257
+ return refused(invalid("The target folder is the source folder.").refusal);
258
+ }
259
+ planned.push({ source, action: action.value });
129
260
  }
130
261
  const rule = await ruleFor(match).catch(notFound);
131
262
  if (!rule.ok)
132
263
  return refused(rule.refusal);
133
- const rejection = organizePredicateRejection({
134
- rule: rule.value,
135
- ...action,
136
- });
137
- if (rejection)
138
- return refused(invalid(rejection.message).refusal);
139
- const job = await queueOrganizeJob(organizeDeps, {
140
- accountConfigId,
141
- userId,
142
- predicate: {
264
+ for (const { action } of planned) {
265
+ const rejection = organizePredicateRejection({
143
266
  rule: rule.value,
144
- sourceMailboxId,
145
267
  ...action,
146
- },
268
+ });
269
+ if (rejection)
270
+ return refused(invalid(rejection.message).refusal);
271
+ }
272
+ const jobs = [];
273
+ for (const { source, action } of planned) {
274
+ const job = await queueOrganizeJob(organizeDeps, {
275
+ accountConfigId,
276
+ userId,
277
+ predicate: {
278
+ rule: rule.value,
279
+ sourceMailboxId: source.mailboxId,
280
+ sentAfter: after ?? FILTER_NO_ACTION,
281
+ sentBefore: before ?? FILTER_NO_ACTION,
282
+ ...action,
283
+ },
284
+ });
285
+ jobs.push({
286
+ accountId: source.accountId,
287
+ sourceMailboxId: source.mailboxId,
288
+ organizeJobId: job.organizeJobId,
289
+ state: job.state,
290
+ });
291
+ }
292
+ const { skipped } = sources.value;
293
+ return asJson({
294
+ jobs,
295
+ ...(skipped.length > 0
296
+ ? {
297
+ skipped,
298
+ message: "These accounts have no confirmed folder for the label yet, so no job was queued for them.",
299
+ }
300
+ : {}),
147
301
  });
148
- return asJson({ organizeJobId: job.organizeJobId, state: job.state });
149
302
  });
150
303
  };
@@ -1,8 +1,8 @@
1
1
  import { NotFoundError } from "@remit/data-ports/errors";
2
2
  import { AddressRole, ContentDisposition, MediaType, } from "@remit/domain-enums";
3
- import { materializeBodyParts, ownedMessages, readClassifierStats, } from "@remit/mailbox-service";
3
+ import { materializeBodyParts, ownedMessages, readClassifierStats, sentDateRange, } from "@remit/mailbox-service";
4
4
  import { z } from "zod";
5
- import { toolError } from "./tool-result.js";
5
+ import { refused, toolError } from "./tool-result.js";
6
6
  export const MAX_BODY_BYTES = 20 * 1024;
7
7
  const DEFAULT_PAGE_SIZE = 50;
8
8
  const MAX_PAGE_SIZE = 50;
@@ -22,6 +22,14 @@ const continuationToken = z
22
22
  .optional()
23
23
  .describe("Opaque cursor from a previous response's continuationToken");
24
24
  const order = z.enum(["asc", "desc"]).optional().describe("Sort order by date");
25
+ const before = z
26
+ .string()
27
+ .optional()
28
+ .describe("Only messages dated before this, an ISO 8601 date-time with zone offset such as 2026-08-13T00:00:00+02:00");
29
+ const after = z
30
+ .string()
31
+ .optional()
32
+ .describe("Only messages dated at or after this, an ISO 8601 date-time with zone offset");
25
33
  const classificationOf = (message, detail) => ({
26
34
  classificationState: message.classificationState,
27
35
  category: message.category,
@@ -116,22 +124,30 @@ export const registerReadTools = (server, deps, accountConfigId) => {
116
124
  return asJson({ accounts: result });
117
125
  });
118
126
  server.registerTool("list_threads", {
119
- description: "List the messages in one folder, newest first, a page at a time.",
127
+ description: "List the messages in one folder, newest first, a page at a time; before and after narrow it to a date range.",
120
128
  inputSchema: {
121
129
  mailboxId: z.string().min(1).describe("Folder from list_mailboxes"),
130
+ before,
131
+ after,
122
132
  limit: pageSize,
123
133
  continuationToken,
124
134
  order,
125
135
  },
126
136
  annotations: READ_ONLY,
127
- }, async (args) => asJson(await toPage(deps, await deps.threadMessage.listByMailbox(accountConfigId, args.mailboxId, {
128
- order: args.order ?? "desc",
129
- limit: args.limit ?? DEFAULT_PAGE_SIZE,
130
- continuationToken: args.continuationToken,
131
- excludeDeleted: true,
132
- }), "Confident")));
137
+ }, async (args) => {
138
+ const sent = sentDateRange(args);
139
+ if (!sent.ok)
140
+ return refused(sent.refusal);
141
+ return asJson(await toPage(deps, await deps.threadMessage.searchByDate(accountConfigId, sent.value, {
142
+ order: args.order ?? "desc",
143
+ limit: args.limit ?? DEFAULT_PAGE_SIZE,
144
+ continuationToken: args.continuationToken,
145
+ mailboxIds: new Set([args.mailboxId]),
146
+ excludeDeleted: true,
147
+ }), "Confident"));
148
+ });
133
149
  server.registerTool("search_threads", {
134
- description: "Search the user's mail by free text, with optional sender, subject and state filters; narrow to one folder with mailboxId.",
150
+ description: "Search the user's mail by free text, with optional sender, subject, state and date filters; narrow to one folder with mailboxId.",
135
151
  inputSchema: {
136
152
  query: z.string().trim().min(1).describe("Free-text search"),
137
153
  from: z.string().optional().describe("Sender address or name"),
@@ -143,27 +159,35 @@ export const registerReadTools = (server, deps, accountConfigId) => {
143
159
  .boolean()
144
160
  .optional()
145
161
  .describe("Only with, or only without, attachments"),
162
+ before,
163
+ after,
146
164
  limit: pageSize,
147
165
  continuationToken,
148
166
  order,
149
167
  },
150
168
  annotations: READ_ONLY,
151
- }, async (args) => asJson(await toPage(deps, await deps.threadMessage.searchByDate(accountConfigId, {
152
- query: args.query,
153
- from: args.from,
154
- subject: args.subject,
155
- unread: args.unread,
156
- starred: args.starred,
157
- attachments: args.attachments,
158
- }, {
159
- order: args.order ?? "desc",
160
- limit: args.limit ?? DEFAULT_PAGE_SIZE,
161
- continuationToken: args.continuationToken,
162
- mailboxIds: args.mailboxId
163
- ? new Set([args.mailboxId])
164
- : undefined,
165
- excludeDeleted: true,
166
- }), "Confident")));
169
+ }, async (args) => {
170
+ const sent = sentDateRange(args);
171
+ if (!sent.ok)
172
+ return refused(sent.refusal);
173
+ return asJson(await toPage(deps, await deps.threadMessage.searchByDate(accountConfigId, {
174
+ ...sent.value,
175
+ query: args.query,
176
+ from: args.from,
177
+ subject: args.subject,
178
+ unread: args.unread,
179
+ starred: args.starred,
180
+ attachments: args.attachments,
181
+ }, {
182
+ order: args.order ?? "desc",
183
+ limit: args.limit ?? DEFAULT_PAGE_SIZE,
184
+ continuationToken: args.continuationToken,
185
+ mailboxIds: args.mailboxId
186
+ ? new Set([args.mailboxId])
187
+ : undefined,
188
+ excludeDeleted: true,
189
+ }), "Confident"));
190
+ });
167
191
  server.registerTool("get_thread", {
168
192
  description: "Fetch the messages of one conversation, oldest first, at envelope level.",
169
193
  inputSchema: {
@@ -0,0 +1,3 @@
1
+ import { type Refusable } from "@remit/mailbox-service";
2
+ import type { McpDeps } from "./deps.js";
3
+ export declare const trashFolderOf: (deps: Pick<McpDeps, "mailboxSpecialUse">, accountId: string) => Promise<Refusable<string>>;
package/dist/trash.js ADDED
@@ -0,0 +1,9 @@
1
+ import { trashMailboxAt } from "@remit/data-ports/folder-role";
2
+ import { accept, invalid } from "@remit/mailbox-service";
3
+ export const trashFolderOf = async (deps, accountId) => {
4
+ const trash = trashMailboxAt(await deps.mailboxSpecialUse.resolveTrashRole(accountId), "resolved");
5
+ if (!trash.allowed) {
6
+ return invalid(`Account ${accountId} has no Trash folder Remit can move mail into. Set one under Folder roles in settings.`);
7
+ }
8
+ return accept(trash.mailbox.mailboxId);
9
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mcp-server",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "The remit MCP server: mail tools over streamable HTTP and the OAuth authorization server that guards them.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -25,12 +25,12 @@
25
25
  "@better-auth/oauth-provider": "^1.7.12",
26
26
  "@hono/node-server": "^2.1.3",
27
27
  "@modelcontextprotocol/sdk": "1.32.1",
28
- "@remit/auth-service": "0.3.9",
29
- "@remit/data-ports": "2.7.0",
30
- "@remit/domain-enums": "0.0.51",
31
- "@remit/drizzle-service": "3.5.0",
32
- "@remit/mailbox-service": "2.19.0",
33
- "@remit/storage-service": "0.0.37",
28
+ "@remit/auth-service": "0.3.11",
29
+ "@remit/data-ports": "2.8.0",
30
+ "@remit/domain-enums": "0.0.53",
31
+ "@remit/drizzle-service": "3.6.0",
32
+ "@remit/mailbox-service": "2.21.0",
33
+ "@remit/storage-service": "0.0.39",
34
34
  "@remit/ui": "0.0.234",
35
35
  "better-auth": "^1.7.12",
36
36
  "drizzle-orm": "1.0.0-rc.5",
@@ -38,12 +38,12 @@
38
38
  "react": "^19",
39
39
  "react-dom": "^19",
40
40
  "zod": "^4.6.7",
41
- "@remit/backend": "5.5.0",
41
+ "@remit/backend": "5.7.0",
42
42
  "@types/react": "^19",
43
43
  "@types/react-dom": "^19"
44
44
  },
45
45
  "devDependencies": {
46
- "@remit/imap-worker": "3.2.2",
46
+ "@remit/imap-worker": "3.3.0",
47
47
  "@tailwindcss/vite": "^4",
48
48
  "tailwindcss": "^4",
49
49
  "tsx": "*",