@marlinjai/email-mcp 1.8.1 → 1.10.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/README.md +63 -12
- package/dist/index.js +810 -136
- package/dist/index.js.map +4 -4
- package/dist/setup/wizard.js +397 -84
- package/dist/setup/wizard.js.map +2 -2
- package/package.json +1 -1
package/dist/setup/wizard.js
CHANGED
|
@@ -830,6 +830,14 @@ function extractAttachments(payload) {
|
|
|
830
830
|
}
|
|
831
831
|
return attachments;
|
|
832
832
|
}
|
|
833
|
+
function threadingHeaders(headers) {
|
|
834
|
+
const picked = {};
|
|
835
|
+
for (const name of ["Message-ID", "In-Reply-To", "References"]) {
|
|
836
|
+
const value = getHeader(headers, name);
|
|
837
|
+
if (value) picked[name.toLowerCase()] = value;
|
|
838
|
+
}
|
|
839
|
+
return Object.keys(picked).length > 0 ? picked : void 0;
|
|
840
|
+
}
|
|
833
841
|
function mapGmailMessage(message, accountId) {
|
|
834
842
|
const headers = message.payload?.headers || [];
|
|
835
843
|
const labelIds = message.labelIds || [];
|
|
@@ -864,6 +872,7 @@ function mapGmailMessage(message, accountId) {
|
|
|
864
872
|
snippet: message.snippet,
|
|
865
873
|
attachments,
|
|
866
874
|
labels: labelIds,
|
|
875
|
+
headers: threadingHeaders(headers),
|
|
867
876
|
flags: {
|
|
868
877
|
read: !labelIds.includes("UNREAD"),
|
|
869
878
|
starred: labelIds.includes("STARRED"),
|
|
@@ -1084,7 +1093,7 @@ var GmailAdapter = class {
|
|
|
1084
1093
|
const raw = await this.buildRaw(params);
|
|
1085
1094
|
const res = await gmail.users.messages.send({
|
|
1086
1095
|
userId: "me",
|
|
1087
|
-
requestBody: { raw }
|
|
1096
|
+
requestBody: { raw, ...this.threadFor(params) }
|
|
1088
1097
|
});
|
|
1089
1098
|
return {
|
|
1090
1099
|
id: res.data.id || "",
|
|
@@ -1097,19 +1106,41 @@ var GmailAdapter = class {
|
|
|
1097
1106
|
const res = await gmail.users.drafts.create({
|
|
1098
1107
|
userId: "me",
|
|
1099
1108
|
requestBody: {
|
|
1100
|
-
message: { raw }
|
|
1109
|
+
message: { raw, ...this.threadFor(params) }
|
|
1101
1110
|
}
|
|
1102
1111
|
});
|
|
1103
1112
|
return { id: res.data.id || "" };
|
|
1104
1113
|
}
|
|
1105
|
-
|
|
1114
|
+
/**
|
|
1115
|
+
* drafts.update replaces the whole message, so a reply draft would fall out
|
|
1116
|
+
* of its thread. When the caller names no thread, the thread and the reply
|
|
1117
|
+
* headers of the stored draft are carried over into the new message.
|
|
1118
|
+
*/
|
|
1119
|
+
async updateDraft(draftId, update) {
|
|
1106
1120
|
const gmail = this.ensureConnected();
|
|
1121
|
+
let params = update;
|
|
1122
|
+
if (update.threadId === void 0) {
|
|
1123
|
+
const existing = await gmail.users.drafts.get({
|
|
1124
|
+
userId: "me",
|
|
1125
|
+
id: draftId,
|
|
1126
|
+
// Ids, labels and headers, no body. drafts.get has no way to ask for
|
|
1127
|
+
// single headers (messages.get does), so all of them come back.
|
|
1128
|
+
format: "metadata"
|
|
1129
|
+
});
|
|
1130
|
+
const message = existing.data.message;
|
|
1131
|
+
const header = (name) => (message?.payload?.headers || []).find((h) => (h.name || "").toLowerCase() === name)?.value || void 0;
|
|
1132
|
+
const inReplyTo = update.inReplyTo ?? header("in-reply-to");
|
|
1133
|
+
const references = update.references ?? header("references")?.split(/\s+/).filter(Boolean);
|
|
1134
|
+
if (message?.threadId && inReplyTo) {
|
|
1135
|
+
params = { ...update, threadId: message.threadId, inReplyTo, references: references?.length ? references : [inReplyTo] };
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1107
1138
|
const raw = await this.buildRaw(params);
|
|
1108
1139
|
const res = await gmail.users.drafts.update({
|
|
1109
1140
|
userId: "me",
|
|
1110
1141
|
id: draftId,
|
|
1111
1142
|
requestBody: {
|
|
1112
|
-
message: { raw }
|
|
1143
|
+
message: { raw, ...this.threadFor(params) }
|
|
1113
1144
|
}
|
|
1114
1145
|
});
|
|
1115
1146
|
return { id: res.data.id || draftId };
|
|
@@ -1377,23 +1408,36 @@ var GmailAdapter = class {
|
|
|
1377
1408
|
requestBody: { addLabelIds: ["SPAM"], removeLabelIds: ["INBOX"] }
|
|
1378
1409
|
});
|
|
1379
1410
|
}
|
|
1380
|
-
|
|
1381
|
-
const gmail = this.ensureConnected();
|
|
1411
|
+
filterCriteria(matchType, value) {
|
|
1382
1412
|
const criteria = {};
|
|
1383
|
-
switch (
|
|
1413
|
+
switch (matchType) {
|
|
1384
1414
|
case "senderDomain":
|
|
1385
|
-
criteria.from = `@${
|
|
1415
|
+
criteria.from = `@${value}`;
|
|
1386
1416
|
break;
|
|
1387
1417
|
case "senderAddress":
|
|
1388
|
-
criteria.from =
|
|
1418
|
+
criteria.from = value;
|
|
1389
1419
|
break;
|
|
1390
1420
|
case "subjectContains":
|
|
1391
|
-
criteria.subject =
|
|
1421
|
+
criteria.subject = value;
|
|
1392
1422
|
break;
|
|
1393
1423
|
case "headerContains":
|
|
1394
|
-
criteria.query =
|
|
1424
|
+
criteria.query = value;
|
|
1395
1425
|
break;
|
|
1396
1426
|
}
|
|
1427
|
+
return criteria;
|
|
1428
|
+
}
|
|
1429
|
+
/** The reverse of filterCriteria, for listing. A best-effort reading of filters made elsewhere. */
|
|
1430
|
+
readCriteria(criteria) {
|
|
1431
|
+
if (typeof criteria?.from === "string" && criteria.from.startsWith("@")) {
|
|
1432
|
+
return { matchType: "senderDomain", value: criteria.from.slice(1) };
|
|
1433
|
+
}
|
|
1434
|
+
if (criteria?.from) return { matchType: "senderAddress", value: criteria.from };
|
|
1435
|
+
if (criteria?.subject) return { matchType: "subjectContains", value: criteria.subject };
|
|
1436
|
+
return { matchType: "headerContains", value: criteria?.query || "" };
|
|
1437
|
+
}
|
|
1438
|
+
async createBlockRule(rule) {
|
|
1439
|
+
const gmail = this.ensureConnected();
|
|
1440
|
+
const criteria = this.filterCriteria(rule.matchType, rule.value);
|
|
1397
1441
|
const action = rule.action === "delete" ? { addLabelIds: ["TRASH"] } : { removeLabelIds: ["INBOX"] };
|
|
1398
1442
|
const res = await gmail.users.settings.filters.create({
|
|
1399
1443
|
userId: "me",
|
|
@@ -1404,25 +1448,11 @@ var GmailAdapter = class {
|
|
|
1404
1448
|
async listBlockRules() {
|
|
1405
1449
|
const gmail = this.ensureConnected();
|
|
1406
1450
|
const res = await gmail.users.settings.filters.list({ userId: "me" });
|
|
1407
|
-
return (res.data.filter || []).map((f) => {
|
|
1408
|
-
const criteria = f.criteria || {};
|
|
1451
|
+
return (res.data.filter || []).filter((f) => !f.action?.forward).map((f) => {
|
|
1409
1452
|
const addLabelIds = f.action?.addLabelIds || [];
|
|
1410
|
-
let matchType = "headerContains";
|
|
1411
|
-
let value = criteria.query || "";
|
|
1412
|
-
if (typeof criteria.from === "string" && criteria.from.startsWith("@")) {
|
|
1413
|
-
matchType = "senderDomain";
|
|
1414
|
-
value = criteria.from.slice(1);
|
|
1415
|
-
} else if (criteria.from) {
|
|
1416
|
-
matchType = "senderAddress";
|
|
1417
|
-
value = criteria.from;
|
|
1418
|
-
} else if (criteria.subject) {
|
|
1419
|
-
matchType = "subjectContains";
|
|
1420
|
-
value = criteria.subject;
|
|
1421
|
-
}
|
|
1422
1453
|
return {
|
|
1423
1454
|
id: f.id || "",
|
|
1424
|
-
|
|
1425
|
-
value,
|
|
1455
|
+
...this.readCriteria(f.criteria),
|
|
1426
1456
|
action: addLabelIds.includes("TRASH") ? "delete" : "moveToJunk",
|
|
1427
1457
|
createdAt: ""
|
|
1428
1458
|
// Gmail's filter API does not expose a creation timestamp
|
|
@@ -1433,6 +1463,72 @@ var GmailAdapter = class {
|
|
|
1433
1463
|
const gmail = this.ensureConnected();
|
|
1434
1464
|
await gmail.users.settings.filters.delete({ userId: "me", id: ruleId });
|
|
1435
1465
|
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Gmail only forwards to an address the user has added and confirmed under
|
|
1468
|
+
* Settings, "Forwarding and POP/IMAP". Adding one through the API needs a
|
|
1469
|
+
* scope this app does not request (gmail.settings.sharing), so a missing or
|
|
1470
|
+
* unconfirmed address is answered with the manual step, before Gmail's own
|
|
1471
|
+
* less helpful error.
|
|
1472
|
+
*/
|
|
1473
|
+
async createForwardRule(rule) {
|
|
1474
|
+
const gmail = this.ensureConnected();
|
|
1475
|
+
const target = rule.forwardTo.trim().toLowerCase();
|
|
1476
|
+
const addresses = await gmail.users.settings.forwardingAddresses.list({ userId: "me" });
|
|
1477
|
+
const known = (addresses.data.forwardingAddresses || []).find(
|
|
1478
|
+
(a) => (a.forwardingEmail || "").toLowerCase() === target
|
|
1479
|
+
);
|
|
1480
|
+
if (!known) {
|
|
1481
|
+
throw new Error(
|
|
1482
|
+
`${rule.forwardTo} is not a forwarding address of this Gmail account yet. Add it once in Gmail under Settings, "Forwarding and POP/IMAP", "Add a forwarding address", confirm the email Google sends to it, then run this again.`
|
|
1483
|
+
);
|
|
1484
|
+
}
|
|
1485
|
+
if (known.verificationStatus !== "accepted") {
|
|
1486
|
+
throw new Error(
|
|
1487
|
+
`${rule.forwardTo} was added to this Gmail account but not confirmed yet. Open the confirmation email Google sent to that address, confirm it, then run this again.`
|
|
1488
|
+
);
|
|
1489
|
+
}
|
|
1490
|
+
const existing = (await this.listForwardRules()).find(
|
|
1491
|
+
(r) => r.matchType === rule.matchType && r.value.toLowerCase() === rule.value.toLowerCase() && r.forwardTo.toLowerCase() === target
|
|
1492
|
+
);
|
|
1493
|
+
if (existing) return { id: existing.id, alreadyExisted: true };
|
|
1494
|
+
const action = { forward: known.forwardingEmail };
|
|
1495
|
+
if (!rule.keepInInbox) action.removeLabelIds = ["INBOX"];
|
|
1496
|
+
const res = await gmail.users.settings.filters.create({
|
|
1497
|
+
userId: "me",
|
|
1498
|
+
requestBody: { criteria: this.filterCriteria(rule.matchType, rule.value), action }
|
|
1499
|
+
});
|
|
1500
|
+
return { id: res.data.id || "" };
|
|
1501
|
+
}
|
|
1502
|
+
/** Every filter that forwards, including ones made by hand in Gmail. */
|
|
1503
|
+
async listForwardRules() {
|
|
1504
|
+
const gmail = this.ensureConnected();
|
|
1505
|
+
const res = await gmail.users.settings.filters.list({ userId: "me" });
|
|
1506
|
+
return (res.data.filter || []).filter((f) => f.action?.forward).map((f) => ({
|
|
1507
|
+
id: f.id || "",
|
|
1508
|
+
...this.readCriteria(f.criteria),
|
|
1509
|
+
forwardTo: f.action.forward,
|
|
1510
|
+
keepInInbox: !(f.action.removeLabelIds || []).includes("INBOX"),
|
|
1511
|
+
createdAt: ""
|
|
1512
|
+
// Gmail's filter API does not expose a creation timestamp
|
|
1513
|
+
}));
|
|
1514
|
+
}
|
|
1515
|
+
async deleteForwardRule(ruleId) {
|
|
1516
|
+
const rules = await this.listForwardRules();
|
|
1517
|
+
if (!rules.some((r) => r.id === ruleId)) {
|
|
1518
|
+
throw new Error(`No forwarding rule with id ${ruleId} on this account. Use email_list_forward_rules to see the ids.`);
|
|
1519
|
+
}
|
|
1520
|
+
await this.deleteBlockRule(ruleId);
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Gmail files a message into an existing thread only when the threadId
|
|
1524
|
+
* comes with In-Reply-To and References headers and a subject (which has to
|
|
1525
|
+
* match the thread's; the reply tool builds "Re: <original subject>"). A
|
|
1526
|
+
* threadId without them is left out, and the message starts its own thread.
|
|
1527
|
+
*/
|
|
1528
|
+
threadFor(params) {
|
|
1529
|
+
const belongs = params.threadId && params.inReplyTo && params.references?.length && params.subject.trim();
|
|
1530
|
+
return belongs ? { threadId: params.threadId } : {};
|
|
1531
|
+
}
|
|
1436
1532
|
/**
|
|
1437
1533
|
* Builds the base64url `raw` payload Gmail expects for send, draft create and
|
|
1438
1534
|
* draft update: a full MIME message with a text part, an HTML part when one
|
|
@@ -1593,7 +1689,36 @@ function buildGraphFilter(query) {
|
|
|
1593
1689
|
}
|
|
1594
1690
|
|
|
1595
1691
|
// src/providers/outlook/adapter.ts
|
|
1596
|
-
var
|
|
1692
|
+
var toGraphAttachment = (att) => ({
|
|
1693
|
+
"@odata.type": "#microsoft.graph.fileAttachment",
|
|
1694
|
+
name: att.filename,
|
|
1695
|
+
contentType: att.contentType,
|
|
1696
|
+
contentBytes: att.content.toString("base64")
|
|
1697
|
+
});
|
|
1698
|
+
var OUTLOOK_MAX_ATTACHMENT_BYTES = 3 * 1024 * 1024;
|
|
1699
|
+
var OUTLOOK_MAX_REQUEST_BYTES = 4 * 1024 * 1024;
|
|
1700
|
+
var UPLOAD_SESSION_HINT = "Larger files need an upload session, which email-mcp does not implement for Outlook yet.";
|
|
1701
|
+
function megabytes(bytes) {
|
|
1702
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1703
|
+
}
|
|
1704
|
+
function assertOutlookAttachmentSizes(attachments) {
|
|
1705
|
+
if (!attachments?.length) return;
|
|
1706
|
+
const limit = megabytes(OUTLOOK_MAX_ATTACHMENT_BYTES);
|
|
1707
|
+
for (const att of attachments) {
|
|
1708
|
+
if (att.content.length > OUTLOOK_MAX_ATTACHMENT_BYTES) {
|
|
1709
|
+
throw new Error(`Outlook accepts attachments up to ${limit} through email-mcp; ${att.filename} is ${megabytes(att.content.length)}. ${UPLOAD_SESSION_HINT}`);
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
function assertOutlookRequestSize(body) {
|
|
1714
|
+
const bytes = Buffer.byteLength(JSON.stringify(body));
|
|
1715
|
+
if (bytes > OUTLOOK_MAX_REQUEST_BYTES) {
|
|
1716
|
+
throw new Error(
|
|
1717
|
+
`Outlook accepts requests up to ${megabytes(OUTLOOK_MAX_REQUEST_BYTES)} through email-mcp; this message is ${megabytes(bytes)} once its attachments are base64-encoded. ${UPLOAD_SESSION_HINT}`
|
|
1718
|
+
);
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
var OutlookAdapter = class _OutlookAdapter {
|
|
1597
1722
|
providerType = ProviderType.Outlook;
|
|
1598
1723
|
client = null;
|
|
1599
1724
|
accountId = "";
|
|
@@ -1732,31 +1857,125 @@ var OutlookAdapter = class {
|
|
|
1732
1857
|
meta: mapGraphAttachment(attachment)
|
|
1733
1858
|
};
|
|
1734
1859
|
}
|
|
1860
|
+
/**
|
|
1861
|
+
* Graph only threads a reply (conversationId, subject, quoted history)
|
|
1862
|
+
* through the createReply flow: POST /me/messages and /me/sendMail always
|
|
1863
|
+
* start a new conversation whatever In-Reply-To says. Build the reply draft,
|
|
1864
|
+
* overlay our subject, body, recipients and attachments, and return its id.
|
|
1865
|
+
* Note the body replaces Graph's pre-filled quoted history.
|
|
1866
|
+
*
|
|
1867
|
+
* Each attachment goes up in a request of its own, so the sizes are checked
|
|
1868
|
+
* per file, and before createReply so that a file that is too big leaves no
|
|
1869
|
+
* draft behind. A draft that cannot be filled is deleted again. (A draft
|
|
1870
|
+
* whose send failed is kept, see `sendEmail`.)
|
|
1871
|
+
*/
|
|
1872
|
+
async createReplyDraft(params, replyToId) {
|
|
1873
|
+
const client = this.ensureClient();
|
|
1874
|
+
assertOutlookAttachmentSizes(params.attachments);
|
|
1875
|
+
const draft = await client.api(`/me/messages/${encodeURIComponent(replyToId)}/createReply`).post({});
|
|
1876
|
+
try {
|
|
1877
|
+
await this.fillReplyDraft(draft.id, params);
|
|
1878
|
+
} catch (error) {
|
|
1879
|
+
throw await this.discardDraft(draft.id, error);
|
|
1880
|
+
}
|
|
1881
|
+
return draft.id;
|
|
1882
|
+
}
|
|
1883
|
+
async fillReplyDraft(draftId, params) {
|
|
1884
|
+
const client = this.ensureClient();
|
|
1885
|
+
const toGraphRecipient = (c) => ({
|
|
1886
|
+
emailAddress: { name: c.name, address: c.email }
|
|
1887
|
+
});
|
|
1888
|
+
const patch = {
|
|
1889
|
+
// Always the caller's subject: Graph pre-fills its own "RE: ..." one.
|
|
1890
|
+
subject: params.subject,
|
|
1891
|
+
body: { contentType: params.body.html ? "html" : "text", content: params.body.html || params.body.text || "" }
|
|
1892
|
+
};
|
|
1893
|
+
if (params.to?.length) patch.toRecipients = params.to.map(toGraphRecipient);
|
|
1894
|
+
if (params.cc !== void 0) patch.ccRecipients = params.cc.map(toGraphRecipient);
|
|
1895
|
+
if (params.bcc !== void 0) patch.bccRecipients = params.bcc.map(toGraphRecipient);
|
|
1896
|
+
await client.api(`/me/messages/${encodeURIComponent(draftId)}`).patch(patch);
|
|
1897
|
+
for (const att of params.attachments ?? []) {
|
|
1898
|
+
await client.api(`/me/messages/${encodeURIComponent(draftId)}/attachments`).post(toGraphAttachment(att));
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
/**
|
|
1902
|
+
* Deletes a reply draft that could not be filled, so a failed reply leaves
|
|
1903
|
+
* nothing half-built in Drafts. Returns the error to throw: the original
|
|
1904
|
+
* one, or, when the delete fails as well, the original message with a note
|
|
1905
|
+
* that the draft is still there.
|
|
1906
|
+
*/
|
|
1907
|
+
async discardDraft(draftId, cause) {
|
|
1908
|
+
try {
|
|
1909
|
+
await this.ensureClient().api(`/me/messages/${encodeURIComponent(draftId)}`).delete();
|
|
1910
|
+
return cause;
|
|
1911
|
+
} catch (deleteError) {
|
|
1912
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
1913
|
+
return new Error(
|
|
1914
|
+
`${message} (the reply draft ${draftId} could not be removed and was left in Drafts: ${deleteError?.message ?? deleteError})`,
|
|
1915
|
+
{ cause }
|
|
1916
|
+
);
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1735
1919
|
async sendEmail(params) {
|
|
1736
1920
|
const client = this.ensureClient();
|
|
1921
|
+
if (params.replyToGraphId) {
|
|
1922
|
+
const draftId = await this.createReplyDraft(params, params.replyToGraphId);
|
|
1923
|
+
try {
|
|
1924
|
+
await client.api(`/me/messages/${encodeURIComponent(draftId)}/send`).post({});
|
|
1925
|
+
} catch (error) {
|
|
1926
|
+
throw new Error(
|
|
1927
|
+
`${error?.message ?? error} (the reply was saved as draft ${draftId} in Drafts and was not confirmed as sent; check Sent Items before sending it again)`,
|
|
1928
|
+
{ cause: error }
|
|
1929
|
+
);
|
|
1930
|
+
}
|
|
1931
|
+
return { id: draftId };
|
|
1932
|
+
}
|
|
1933
|
+
assertOutlookAttachmentSizes(params.attachments);
|
|
1737
1934
|
const message = this.buildGraphMessage(params);
|
|
1738
1935
|
const payload = { message };
|
|
1739
1936
|
if (params.attachments?.length) {
|
|
1740
|
-
payload.message.attachments = params.attachments.map(
|
|
1741
|
-
"@odata.type": "#microsoft.graph.fileAttachment",
|
|
1742
|
-
name: att.filename,
|
|
1743
|
-
contentType: att.contentType,
|
|
1744
|
-
contentBytes: att.content.toString("base64")
|
|
1745
|
-
}));
|
|
1937
|
+
payload.message.attachments = params.attachments.map(toGraphAttachment);
|
|
1746
1938
|
}
|
|
1939
|
+
assertOutlookRequestSize(payload);
|
|
1747
1940
|
await client.api("/me/sendMail").post(payload);
|
|
1748
1941
|
return { id: `sent-${Date.now()}` };
|
|
1749
1942
|
}
|
|
1750
1943
|
async createDraft(params) {
|
|
1751
1944
|
const client = this.ensureClient();
|
|
1945
|
+
if (params.replyToGraphId) {
|
|
1946
|
+
return { id: await this.createReplyDraft(params, params.replyToGraphId) };
|
|
1947
|
+
}
|
|
1948
|
+
assertOutlookAttachmentSizes(params.attachments);
|
|
1752
1949
|
const message = this.buildGraphMessage(params);
|
|
1950
|
+
if (params.attachments?.length) {
|
|
1951
|
+
message.attachments = params.attachments.map(toGraphAttachment);
|
|
1952
|
+
}
|
|
1953
|
+
assertOutlookRequestSize(message);
|
|
1753
1954
|
const result = await client.api("/me/messages").post(message);
|
|
1754
1955
|
return { id: result.id };
|
|
1755
1956
|
}
|
|
1756
1957
|
async updateDraft(draftId, params) {
|
|
1757
1958
|
const client = this.ensureClient();
|
|
1959
|
+
assertOutlookAttachmentSizes(params.attachments);
|
|
1758
1960
|
const message = this.buildGraphMessage(params);
|
|
1759
1961
|
await client.api(`/me/messages/${encodeURIComponent(draftId)}`).patch(message);
|
|
1962
|
+
if (params.attachments) {
|
|
1963
|
+
const attachmentsPath = `/me/messages/${encodeURIComponent(draftId)}/attachments`;
|
|
1964
|
+
const previousIds = [];
|
|
1965
|
+
let page = await client.api(attachmentsPath).select("id").get();
|
|
1966
|
+
for (; ; ) {
|
|
1967
|
+
previousIds.push(...(page.value || []).map((att) => att.id));
|
|
1968
|
+
const next = page["@odata.nextLink"];
|
|
1969
|
+
if (!next) break;
|
|
1970
|
+
page = await client.api(next).get();
|
|
1971
|
+
}
|
|
1972
|
+
for (const att of params.attachments) {
|
|
1973
|
+
await client.api(attachmentsPath).post(toGraphAttachment(att));
|
|
1974
|
+
}
|
|
1975
|
+
for (const id of previousIds) {
|
|
1976
|
+
await client.api(`${attachmentsPath}/${encodeURIComponent(id)}`).delete();
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1760
1979
|
return { id: draftId };
|
|
1761
1980
|
}
|
|
1762
1981
|
async listDrafts(limit, offset) {
|
|
@@ -1961,28 +2180,53 @@ var OutlookAdapter = class {
|
|
|
1961
2180
|
* before this scope was added need to re-run the setup wizard to
|
|
1962
2181
|
* re-consent — old tokens will 403 on this call until then.
|
|
1963
2182
|
*/
|
|
1964
|
-
|
|
1965
|
-
const client = this.ensureClient();
|
|
2183
|
+
ruleConditions(matchType, value) {
|
|
1966
2184
|
const conditions = {};
|
|
1967
|
-
switch (
|
|
2185
|
+
switch (matchType) {
|
|
1968
2186
|
case "senderDomain":
|
|
1969
2187
|
case "senderAddress":
|
|
1970
|
-
conditions.senderContains = [
|
|
2188
|
+
conditions.senderContains = [value];
|
|
1971
2189
|
break;
|
|
1972
2190
|
case "subjectContains":
|
|
1973
|
-
conditions.subjectContains = [
|
|
2191
|
+
conditions.subjectContains = [value];
|
|
1974
2192
|
break;
|
|
1975
2193
|
case "headerContains":
|
|
1976
|
-
conditions.headerContains = [
|
|
2194
|
+
conditions.headerContains = [value];
|
|
1977
2195
|
break;
|
|
1978
2196
|
}
|
|
2197
|
+
return conditions;
|
|
2198
|
+
}
|
|
2199
|
+
// Graph doesn't tag which of our four matchTypes a rule came from:
|
|
2200
|
+
// this reconstruction is a best-effort heuristic for listing/auditing,
|
|
2201
|
+
// not a guaranteed round-trip of what was originally requested.
|
|
2202
|
+
readConditions(conditions) {
|
|
2203
|
+
if (conditions?.senderContains?.length) {
|
|
2204
|
+
const value = conditions.senderContains[0];
|
|
2205
|
+
return { matchType: value.includes("@") ? "senderAddress" : "senderDomain", value };
|
|
2206
|
+
}
|
|
2207
|
+
if (conditions?.subjectContains?.length) return { matchType: "subjectContains", value: conditions.subjectContains[0] };
|
|
2208
|
+
if (conditions?.headerContains?.length) return { matchType: "headerContains", value: conditions.headerContains[0] };
|
|
2209
|
+
return { matchType: "headerContains", value: "" };
|
|
2210
|
+
}
|
|
2211
|
+
/** Addresses a rule sends mail on to, whether by "forward" or "redirect". */
|
|
2212
|
+
static forwardTargets(actions) {
|
|
2213
|
+
return [...actions?.forwardTo || [], ...actions?.redirectTo || [], ...actions?.forwardAsAttachmentTo || []].map((r) => r?.emailAddress?.address).filter((a) => typeof a === "string" && a.length > 0);
|
|
2214
|
+
}
|
|
2215
|
+
async listMessageRules() {
|
|
2216
|
+
const client = this.ensureClient();
|
|
2217
|
+
const response = await client.api("/me/mailFolders/inbox/messageRules").get();
|
|
2218
|
+
return response.value || [];
|
|
2219
|
+
}
|
|
2220
|
+
async createBlockRule(rule) {
|
|
2221
|
+
const client = this.ensureClient();
|
|
2222
|
+
const conditions = this.ruleConditions(rule.matchType, rule.value);
|
|
1979
2223
|
const actions = { stopProcessingRules: true };
|
|
1980
2224
|
if (rule.action === "delete") {
|
|
1981
2225
|
actions.delete = true;
|
|
1982
2226
|
} else {
|
|
1983
2227
|
actions.moveToFolder = await this.resolveFolder("junkemail");
|
|
1984
2228
|
}
|
|
1985
|
-
const existing = await this.
|
|
2229
|
+
const existing = await this.listMessageRules();
|
|
1986
2230
|
const result = await client.api("/me/mailFolders/inbox/messageRules").post({
|
|
1987
2231
|
displayName: `email-mcp: ${rule.matchType} "${rule.value}"`.slice(0, 255),
|
|
1988
2232
|
sequence: existing.length + 1,
|
|
@@ -1993,36 +2237,66 @@ var OutlookAdapter = class {
|
|
|
1993
2237
|
return { id: result.id };
|
|
1994
2238
|
}
|
|
1995
2239
|
async listBlockRules() {
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
value = conditions.senderContains[0];
|
|
2004
|
-
matchType = value.includes("@") ? "senderAddress" : "senderDomain";
|
|
2005
|
-
} else if (conditions.subjectContains?.length) {
|
|
2006
|
-
matchType = "subjectContains";
|
|
2007
|
-
value = conditions.subjectContains[0];
|
|
2008
|
-
} else if (conditions.headerContains?.length) {
|
|
2009
|
-
matchType = "headerContains";
|
|
2010
|
-
value = conditions.headerContains[0];
|
|
2011
|
-
}
|
|
2012
|
-
return {
|
|
2013
|
-
id: r.id,
|
|
2014
|
-
matchType,
|
|
2015
|
-
value,
|
|
2016
|
-
action: r.actions?.delete ? "delete" : "moveToJunk",
|
|
2017
|
-
createdAt: ""
|
|
2018
|
-
// Graph's messageRule resource has no creation timestamp field
|
|
2019
|
-
};
|
|
2020
|
-
});
|
|
2240
|
+
return (await this.listMessageRules()).filter((r) => _OutlookAdapter.forwardTargets(r.actions).length === 0).map((r) => ({
|
|
2241
|
+
id: r.id,
|
|
2242
|
+
...this.readConditions(r.conditions),
|
|
2243
|
+
action: r.actions?.delete ? "delete" : "moveToJunk",
|
|
2244
|
+
createdAt: ""
|
|
2245
|
+
// Graph's messageRule resource has no creation timestamp field
|
|
2246
|
+
}));
|
|
2021
2247
|
}
|
|
2022
2248
|
async deleteBlockRule(ruleId) {
|
|
2023
2249
|
const client = this.ensureClient();
|
|
2024
2250
|
await client.api(`/me/mailFolders/inbox/messageRules/${encodeURIComponent(ruleId)}`).delete();
|
|
2025
2251
|
}
|
|
2252
|
+
/**
|
|
2253
|
+
* Outlook forwards to any address without a confirmation step, so the only
|
|
2254
|
+
* check on the target is the allowlist in the tool layer. Needs the same
|
|
2255
|
+
* MailboxSettings.ReadWrite scope as block rules.
|
|
2256
|
+
*/
|
|
2257
|
+
async createForwardRule(rule) {
|
|
2258
|
+
const client = this.ensureClient();
|
|
2259
|
+
const forwardTo = rule.forwardTo.trim();
|
|
2260
|
+
const target = forwardTo.toLowerCase();
|
|
2261
|
+
const all = await this.listMessageRules();
|
|
2262
|
+
const existing = all.find((r) => {
|
|
2263
|
+
const read = this.readConditions(r.conditions);
|
|
2264
|
+
const sameMatch = read.value.toLowerCase() === rule.value.toLowerCase() && (read.matchType === rule.matchType || read.matchType.startsWith("sender") && rule.matchType.startsWith("sender"));
|
|
2265
|
+
return sameMatch && _OutlookAdapter.forwardTargets(r.actions).some((a) => a.toLowerCase() === target);
|
|
2266
|
+
});
|
|
2267
|
+
if (existing) return { id: existing.id, alreadyExisted: true };
|
|
2268
|
+
const actions = { forwardTo: [{ emailAddress: { address: forwardTo } }] };
|
|
2269
|
+
if (!rule.keepInInbox) {
|
|
2270
|
+
actions.moveToFolder = await this.resolveFolder("archive");
|
|
2271
|
+
actions.stopProcessingRules = true;
|
|
2272
|
+
}
|
|
2273
|
+
const result = await client.api("/me/mailFolders/inbox/messageRules").post({
|
|
2274
|
+
displayName: `email-mcp: forward ${rule.matchType} "${rule.value}" to ${forwardTo}`.slice(0, 255),
|
|
2275
|
+
sequence: all.length + 1,
|
|
2276
|
+
isEnabled: true,
|
|
2277
|
+
conditions: this.ruleConditions(rule.matchType, rule.value),
|
|
2278
|
+
actions
|
|
2279
|
+
});
|
|
2280
|
+
return { id: result.id };
|
|
2281
|
+
}
|
|
2282
|
+
/** Every inbox rule that forwards or redirects, including ones made by hand in Outlook. */
|
|
2283
|
+
async listForwardRules() {
|
|
2284
|
+
return (await this.listMessageRules()).filter((r) => _OutlookAdapter.forwardTargets(r.actions).length > 0).map((r) => ({
|
|
2285
|
+
id: r.id,
|
|
2286
|
+
...this.readConditions(r.conditions),
|
|
2287
|
+
forwardTo: _OutlookAdapter.forwardTargets(r.actions).join(", "),
|
|
2288
|
+
keepInInbox: !r.actions?.moveToFolder && !r.actions?.delete,
|
|
2289
|
+
createdAt: ""
|
|
2290
|
+
// Graph's messageRule resource has no creation timestamp field
|
|
2291
|
+
}));
|
|
2292
|
+
}
|
|
2293
|
+
async deleteForwardRule(ruleId) {
|
|
2294
|
+
const rules = await this.listForwardRules();
|
|
2295
|
+
if (!rules.some((r) => r.id === ruleId)) {
|
|
2296
|
+
throw new Error(`No forwarding rule with id ${ruleId} on this account. Use email_list_forward_rules to see the ids.`);
|
|
2297
|
+
}
|
|
2298
|
+
await this.deleteBlockRule(ruleId);
|
|
2299
|
+
}
|
|
2026
2300
|
async getCategories() {
|
|
2027
2301
|
const client = this.ensureClient();
|
|
2028
2302
|
const response = await client.api("/me/outlook/masterCategories").get();
|
|
@@ -2072,6 +2346,12 @@ function mapImapFolder(imapFolder) {
|
|
|
2072
2346
|
unreadCount: imapFolder.status?.unseen
|
|
2073
2347
|
};
|
|
2074
2348
|
}
|
|
2349
|
+
function imapAttachmentId(att, index) {
|
|
2350
|
+
return att.contentId || `att-${index}`;
|
|
2351
|
+
}
|
|
2352
|
+
function headerValue(line) {
|
|
2353
|
+
return line.slice(line.indexOf(":") + 1).replace(/\r?\n[ \t]+/g, " ").trim();
|
|
2354
|
+
}
|
|
2075
2355
|
function mapParsedEmail(parsed, folder, accountId, uid) {
|
|
2076
2356
|
const mapContact = (addr) => ({
|
|
2077
2357
|
name: addr.name || void 0,
|
|
@@ -2082,7 +2362,7 @@ function mapParsedEmail(parsed, folder, accountId, uid) {
|
|
|
2082
2362
|
return addrs.value.map(mapContact);
|
|
2083
2363
|
};
|
|
2084
2364
|
const attachments = (parsed.attachments || []).map((att, i) => ({
|
|
2085
|
-
id: att
|
|
2365
|
+
id: imapAttachmentId(att, i),
|
|
2086
2366
|
filename: att.filename || `attachment-${i}`,
|
|
2087
2367
|
contentType: att.contentType || "application/octet-stream",
|
|
2088
2368
|
size: att.size || 0
|
|
@@ -2111,7 +2391,7 @@ function mapParsedEmail(parsed, folder, accountId, uid) {
|
|
|
2111
2391
|
flagged: parsed.flags?.has("\\Flagged") || false,
|
|
2112
2392
|
draft: parsed.flags?.has("\\Draft") || false
|
|
2113
2393
|
},
|
|
2114
|
-
headers: parsed.headerLines ? Object.fromEntries(parsed.headerLines.map((h) => [h.key, h.line])) : void 0
|
|
2394
|
+
headers: parsed.headerLines ? Object.fromEntries(parsed.headerLines.map((h) => [h.key, headerValue(h.line)])) : void 0
|
|
2115
2395
|
};
|
|
2116
2396
|
}
|
|
2117
2397
|
|
|
@@ -2508,7 +2788,7 @@ var ImapAdapter = class {
|
|
|
2508
2788
|
}
|
|
2509
2789
|
async getEmail(id, folder) {
|
|
2510
2790
|
const client = await this.ensureConnected();
|
|
2511
|
-
const targetFolder = folder
|
|
2791
|
+
const targetFolder = folder ? await this.resolveFolder(folder) : "INBOX";
|
|
2512
2792
|
let lock;
|
|
2513
2793
|
try {
|
|
2514
2794
|
lock = await client.getMailboxLock(targetFolder);
|
|
@@ -2527,13 +2807,20 @@ var ImapAdapter = class {
|
|
|
2527
2807
|
lock.release();
|
|
2528
2808
|
}
|
|
2529
2809
|
}
|
|
2530
|
-
|
|
2810
|
+
/**
|
|
2811
|
+
* IMAP has no thread object: the thread is the set of messages in ONE
|
|
2812
|
+
* folder whose Message-ID, References or In-Reply-To name `threadId`.
|
|
2813
|
+
* Messages of the same conversation in other folders (the own replies in
|
|
2814
|
+
* Sent, for example) are not part of the result.
|
|
2815
|
+
*/
|
|
2816
|
+
async getThread(threadId, sourceFolder) {
|
|
2531
2817
|
const client = await this.ensureConnected();
|
|
2818
|
+
const folder = sourceFolder ? await this.resolveFolder(sourceFolder) : "INBOX";
|
|
2532
2819
|
let lock;
|
|
2533
2820
|
try {
|
|
2534
|
-
lock = await client.getMailboxLock(
|
|
2821
|
+
lock = await client.getMailboxLock(folder);
|
|
2535
2822
|
} catch (error) {
|
|
2536
|
-
throw formatImapError(error,
|
|
2823
|
+
throw formatImapError(error, `Failed to open folder "${folder}"`);
|
|
2537
2824
|
}
|
|
2538
2825
|
try {
|
|
2539
2826
|
const searchResult = await client.search(
|
|
@@ -2541,13 +2828,13 @@ var ImapAdapter = class {
|
|
|
2541
2828
|
{ uid: true }
|
|
2542
2829
|
);
|
|
2543
2830
|
const uids = Array.isArray(searchResult) ? searchResult : [];
|
|
2544
|
-
if (uids.length === 0) throw new Error(`Thread ${threadId} not found`);
|
|
2831
|
+
if (uids.length === 0) throw new Error(`Thread ${threadId} not found in "${folder}"`);
|
|
2545
2832
|
const messages = [];
|
|
2546
2833
|
for await (const msg of client.fetch(uids, { source: true, uid: true, flags: true })) {
|
|
2547
2834
|
if (!msg.source) continue;
|
|
2548
2835
|
const parsed = await simpleParser(msg.source);
|
|
2549
2836
|
parsed.flags = msg.flags;
|
|
2550
|
-
messages.push(mapParsedEmail(parsed,
|
|
2837
|
+
messages.push(mapParsedEmail(parsed, folder, this.accountId, msg.uid));
|
|
2551
2838
|
}
|
|
2552
2839
|
const participantMap = /* @__PURE__ */ new Map();
|
|
2553
2840
|
for (const msg of messages) {
|
|
@@ -2569,27 +2856,29 @@ var ImapAdapter = class {
|
|
|
2569
2856
|
lock.release();
|
|
2570
2857
|
}
|
|
2571
2858
|
}
|
|
2572
|
-
async getAttachment(emailId, attachmentId) {
|
|
2859
|
+
async getAttachment(emailId, attachmentId, sourceFolder) {
|
|
2573
2860
|
const client = await this.ensureConnected();
|
|
2861
|
+
const folder = sourceFolder ? await this.resolveFolder(sourceFolder) : "INBOX";
|
|
2574
2862
|
let lock;
|
|
2575
2863
|
try {
|
|
2576
|
-
lock = await client.getMailboxLock(
|
|
2864
|
+
lock = await client.getMailboxLock(folder);
|
|
2577
2865
|
} catch (error) {
|
|
2578
|
-
throw formatImapError(error,
|
|
2866
|
+
throw formatImapError(error, `Failed to open folder "${folder}"`);
|
|
2579
2867
|
}
|
|
2580
2868
|
try {
|
|
2581
2869
|
const msg = await client.fetchOne(String(emailId), { source: true, uid: true }, { uid: true });
|
|
2582
|
-
if (!msg) throw new Error(`Email ${emailId} not found`);
|
|
2870
|
+
if (!msg) throw new Error(`Email ${emailId} not found in "${folder}"`);
|
|
2583
2871
|
if (!msg.source) throw new Error(`Email ${emailId} has no source`);
|
|
2584
2872
|
const parsed = await simpleParser(msg.source);
|
|
2585
|
-
const
|
|
2586
|
-
|
|
2587
|
-
);
|
|
2588
|
-
if (
|
|
2873
|
+
const attachments = parsed.attachments || [];
|
|
2874
|
+
let index = attachments.findIndex((att, i) => imapAttachmentId(att, i) === attachmentId);
|
|
2875
|
+
if (index === -1) index = attachments.findIndex((att) => att.filename === attachmentId);
|
|
2876
|
+
if (index === -1) throw new Error(`Attachment ${attachmentId} not found in email ${emailId}`);
|
|
2877
|
+
const attachment = attachments[index];
|
|
2589
2878
|
return {
|
|
2590
2879
|
data: attachment.content,
|
|
2591
2880
|
meta: {
|
|
2592
|
-
id: attachment
|
|
2881
|
+
id: imapAttachmentId(attachment, index),
|
|
2593
2882
|
filename: attachment.filename || "attachment",
|
|
2594
2883
|
contentType: attachment.contentType || "application/octet-stream",
|
|
2595
2884
|
size: attachment.size || 0
|
|
@@ -2626,18 +2915,34 @@ var ImapAdapter = class {
|
|
|
2626
2915
|
* Drafts folder, and IMAP messages are immutable once appended. This
|
|
2627
2916
|
* deletes the old revision and appends a new one, so the returned `id`
|
|
2628
2917
|
* is a NEW uid; the old id no longer resolves after this call.
|
|
2918
|
+
*
|
|
2919
|
+
* A reply draft keeps its place in the thread: when the caller passes no
|
|
2920
|
+
* `inReplyTo`, the In-Reply-To and References headers of the old revision
|
|
2921
|
+
* are copied into the new one.
|
|
2629
2922
|
*/
|
|
2630
2923
|
async updateDraft(draftId, params, sourceFolder) {
|
|
2631
2924
|
const client = await this.ensureConnected();
|
|
2632
2925
|
const draftsFolder = sourceFolder ? await this.resolveFolder(sourceFolder) : await this.resolveFolder("Drafts");
|
|
2633
|
-
const rawMessage = await this.buildDraftMessage(params);
|
|
2634
2926
|
let lock;
|
|
2635
2927
|
try {
|
|
2636
2928
|
lock = await client.getMailboxLock(draftsFolder);
|
|
2637
2929
|
} catch (error) {
|
|
2638
2930
|
throw formatImapError(error, `Failed to open folder "${draftsFolder}"`);
|
|
2639
2931
|
}
|
|
2932
|
+
let rawMessage;
|
|
2640
2933
|
try {
|
|
2934
|
+
let next = params;
|
|
2935
|
+
if (params.inReplyTo === void 0) {
|
|
2936
|
+
const old = await client.fetchOne(String(draftId), { source: true, uid: true }, { uid: true });
|
|
2937
|
+
if (old && old.source) {
|
|
2938
|
+
const parsed = await simpleParser(old.source);
|
|
2939
|
+
const references = [].concat(parsed.references ?? []);
|
|
2940
|
+
if (parsed.inReplyTo) {
|
|
2941
|
+
next = { ...params, inReplyTo: parsed.inReplyTo, references: params.references ?? references };
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
rawMessage = await this.buildDraftMessage(next);
|
|
2641
2946
|
await client.messageDelete(draftId, { uid: true });
|
|
2642
2947
|
} finally {
|
|
2643
2948
|
lock.release();
|
|
@@ -2924,6 +3229,11 @@ var AccountManager = class {
|
|
|
2924
3229
|
connected: this.providers.has(c.id)
|
|
2925
3230
|
}));
|
|
2926
3231
|
}
|
|
3232
|
+
/** The account's own address, or undefined for an unknown account id. */
|
|
3233
|
+
async getAccountEmail(accountId) {
|
|
3234
|
+
const creds = this.credentials.get(accountId) ?? await this.store.get(accountId);
|
|
3235
|
+
return creds?.email;
|
|
3236
|
+
}
|
|
2927
3237
|
async getProvider(accountId) {
|
|
2928
3238
|
const existing = this.providers.get(accountId);
|
|
2929
3239
|
if (existing) {
|
|
@@ -3390,6 +3700,9 @@ async function main() {
|
|
|
3390
3700
|
addMore = await askConfirm("Would you like to add another email account?", false);
|
|
3391
3701
|
}
|
|
3392
3702
|
console.log("\nSetup complete! You can now use the email tools in Claude Code.");
|
|
3703
|
+
console.log(
|
|
3704
|
+
"\nemail-mcp is free and open source. Google requires a yearly independent security\naudit (675 US dollars) for the Gmail sign-in it ships. If email-mcp is useful to you,\nyou can help cover it: https://github.com/sponsors/marlinjai"
|
|
3705
|
+
);
|
|
3393
3706
|
}
|
|
3394
3707
|
main().catch((err) => {
|
|
3395
3708
|
console.error(`
|