zele 0.3.17 → 0.3.20
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 +64 -0
- package/dist/api-utils.d.ts +10 -0
- package/dist/api-utils.js +14 -0
- package/dist/api-utils.js.map +1 -1
- package/dist/cli-types.d.ts +4 -0
- package/dist/cli-types.js +6 -0
- package/dist/cli-types.js.map +1 -0
- package/dist/cli.js +1 -5
- package/dist/cli.js.map +1 -1
- package/dist/commands/attachment.d.ts +2 -2
- package/dist/commands/attachment.js.map +1 -1
- package/dist/commands/auth-cmd.d.ts +2 -2
- package/dist/commands/auth-cmd.js.map +1 -1
- package/dist/commands/calendar.d.ts +2 -2
- package/dist/commands/calendar.js.map +1 -1
- package/dist/commands/draft.d.ts +2 -2
- package/dist/commands/draft.js +51 -3
- package/dist/commands/draft.js.map +1 -1
- package/dist/commands/filter.d.ts +2 -2
- package/dist/commands/filter.js.map +1 -1
- package/dist/commands/label.d.ts +2 -2
- package/dist/commands/label.js.map +1 -1
- package/dist/commands/mail-actions.d.ts +2 -2
- package/dist/commands/mail-actions.js +290 -1
- package/dist/commands/mail-actions.js.map +1 -1
- package/dist/commands/mail.d.ts +2 -2
- package/dist/commands/mail.js +41 -1
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/profile.d.ts +2 -2
- package/dist/commands/profile.js.map +1 -1
- package/dist/commands/watch.d.ts +2 -2
- package/dist/commands/watch.js.map +1 -1
- package/dist/gmail-client.d.ts +59 -3
- package/dist/gmail-client.js +119 -5
- package/dist/gmail-client.js.map +1 -1
- package/dist/imap-smtp-client.d.ts +75 -4
- package/dist/imap-smtp-client.js +131 -7
- package/dist/imap-smtp-client.js.map +1 -1
- package/dist/unsubscribe.d.ts +76 -0
- package/dist/unsubscribe.js +224 -0
- package/dist/unsubscribe.js.map +1 -0
- package/package.json +2 -2
- package/skills/zele/SKILL.md +26 -125
- package/src/api-utils.ts +14 -0
- package/src/cli-types.ts +8 -0
- package/src/cli.ts +2 -7
- package/src/commands/attachment.ts +2 -2
- package/src/commands/auth-cmd.ts +2 -2
- package/src/commands/calendar.ts +2 -2
- package/src/commands/draft.ts +60 -5
- package/src/commands/filter.ts +2 -2
- package/src/commands/label.ts +2 -2
- package/src/commands/mail-actions.ts +315 -4
- package/src/commands/mail.ts +45 -3
- package/src/commands/profile.ts +2 -2
- package/src/commands/watch.ts +2 -2
- package/src/gmail-client.ts +193 -6
- package/src/imap-smtp-client.ts +186 -7
- package/src/unsubscribe.test.ts +487 -0
- package/src/unsubscribe.ts +255 -0
package/src/gmail-client.ts
CHANGED
|
@@ -58,6 +58,7 @@ export interface ParsedMessage {
|
|
|
58
58
|
inReplyTo?: string
|
|
59
59
|
references?: string
|
|
60
60
|
listUnsubscribe?: string
|
|
61
|
+
listUnsubscribePost?: string
|
|
61
62
|
body: string // decoded body (html preferred for rich rendering)
|
|
62
63
|
mimeType: string // 'text/plain' or 'text/html'
|
|
63
64
|
textBody: string | null // decoded text/plain body when available (for reply parsing)
|
|
@@ -102,6 +103,7 @@ export interface ThreadListItem {
|
|
|
102
103
|
inReplyTo: string | null
|
|
103
104
|
hasAttachments: boolean
|
|
104
105
|
listUnsubscribe: string | null
|
|
106
|
+
listUnsubscribePost: string | null
|
|
105
107
|
}
|
|
106
108
|
|
|
107
109
|
export interface ThreadListResult {
|
|
@@ -715,7 +717,7 @@ export class GmailClient {
|
|
|
715
717
|
return res.data
|
|
716
718
|
}
|
|
717
719
|
|
|
718
|
-
async getDraft({ draftId }: { draftId: string }): Promise<NotFoundError | AuthError | ApiError | { id: string; message: ParsedMessage; to:
|
|
720
|
+
async getDraft({ draftId }: { draftId: string }): Promise<NotFoundError | AuthError | ApiError | { id: string; message: ParsedMessage; to: Sender[]; cc: Sender[]; bcc: Sender[] }> {
|
|
719
721
|
const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
|
|
720
722
|
withRetry(() =>
|
|
721
723
|
this.gmail.users.drafts.get({
|
|
@@ -730,14 +732,13 @@ export class GmailClient {
|
|
|
730
732
|
if (!res.data || !res.data.message) return new NotFoundError({ resource: `draft ${draftId}` })
|
|
731
733
|
|
|
732
734
|
const message = this.parseMessage(res.data.message)
|
|
733
|
-
const headers = res.data.message.payload?.headers ?? []
|
|
734
735
|
|
|
735
736
|
return {
|
|
736
737
|
id: res.data.id ?? draftId,
|
|
737
738
|
message,
|
|
738
|
-
to:
|
|
739
|
-
cc:
|
|
740
|
-
bcc:
|
|
739
|
+
to: message.to,
|
|
740
|
+
cc: message.cc ?? [],
|
|
741
|
+
bcc: message.bcc,
|
|
741
742
|
}
|
|
742
743
|
}
|
|
743
744
|
|
|
@@ -816,6 +817,187 @@ export class GmailClient {
|
|
|
816
817
|
)
|
|
817
818
|
}
|
|
818
819
|
|
|
820
|
+
/**
|
|
821
|
+
* Update an existing draft. Gmail replaces the entire message content,
|
|
822
|
+
* so the caller must provide all fields (merge with existing draft before calling).
|
|
823
|
+
*/
|
|
824
|
+
async updateDraft({
|
|
825
|
+
draftId,
|
|
826
|
+
to,
|
|
827
|
+
subject,
|
|
828
|
+
body,
|
|
829
|
+
cc,
|
|
830
|
+
bcc,
|
|
831
|
+
threadId,
|
|
832
|
+
fromEmail,
|
|
833
|
+
attachments,
|
|
834
|
+
}: {
|
|
835
|
+
draftId: string
|
|
836
|
+
to: Array<{ name?: string; email: string }>
|
|
837
|
+
subject: string
|
|
838
|
+
body: string
|
|
839
|
+
cc?: Array<{ name?: string; email: string }>
|
|
840
|
+
bcc?: Array<{ name?: string; email: string }>
|
|
841
|
+
threadId?: string
|
|
842
|
+
fromEmail?: string
|
|
843
|
+
attachments?: Array<{ filename: string; mimeType: string; content: Buffer }>
|
|
844
|
+
}) {
|
|
845
|
+
const raw = this.buildMimeMessage({ to, subject, body, cc, bcc, attachments, fromEmail })
|
|
846
|
+
|
|
847
|
+
const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
|
|
848
|
+
withRetry(() =>
|
|
849
|
+
this.gmail.users.drafts.update({
|
|
850
|
+
userId: 'me',
|
|
851
|
+
id: draftId,
|
|
852
|
+
requestBody: {
|
|
853
|
+
message: { raw, threadId },
|
|
854
|
+
},
|
|
855
|
+
}),
|
|
856
|
+
),
|
|
857
|
+
)
|
|
858
|
+
if (res instanceof Error) return res
|
|
859
|
+
|
|
860
|
+
return res.data
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Create a draft reply to a thread. Reuses the same reply-to resolution,
|
|
865
|
+
* reply-all CC computation, and In-Reply-To/References header logic as
|
|
866
|
+
* replyToThread(), but saves as a draft instead of sending.
|
|
867
|
+
*/
|
|
868
|
+
async createDraftReply({
|
|
869
|
+
threadId,
|
|
870
|
+
body,
|
|
871
|
+
replyAll = false,
|
|
872
|
+
cc,
|
|
873
|
+
fromEmail,
|
|
874
|
+
}: {
|
|
875
|
+
threadId: string
|
|
876
|
+
body: string
|
|
877
|
+
replyAll?: boolean
|
|
878
|
+
cc?: Array<{ email: string }>
|
|
879
|
+
fromEmail?: string
|
|
880
|
+
}): Promise<EmptyThreadError | AuthError | ApiError | gmail_v1.Schema$Draft> {
|
|
881
|
+
const { parsed: thread } = await this.getThread({ threadId })
|
|
882
|
+
if (thread.messages.length === 0) {
|
|
883
|
+
return new EmptyThreadError({ threadId })
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const lastMsg = thread.messages[thread.messages.length - 1]!
|
|
887
|
+
|
|
888
|
+
const replyTo = lastMsg.replyTo ?? lastMsg.from.email
|
|
889
|
+
const to = [{ email: replyTo }]
|
|
890
|
+
|
|
891
|
+
let resolvedCc: Array<{ email: string }> | undefined
|
|
892
|
+
if (replyAll) {
|
|
893
|
+
const profile = await this.getProfile()
|
|
894
|
+
if (profile instanceof Error) return profile
|
|
895
|
+
const myEmail = profile.emailAddress.toLowerCase()
|
|
896
|
+
|
|
897
|
+
const allRecipients = [
|
|
898
|
+
...lastMsg.to.map((r) => r.email),
|
|
899
|
+
...(lastMsg.cc?.map((r) => r.email) ?? []),
|
|
900
|
+
]
|
|
901
|
+
.filter((e) => e.toLowerCase() !== myEmail)
|
|
902
|
+
.filter((e) => e.toLowerCase() !== replyTo.toLowerCase())
|
|
903
|
+
|
|
904
|
+
if (allRecipients.length > 0) {
|
|
905
|
+
resolvedCc = allRecipients.map((e) => ({ email: e }))
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
if (cc) {
|
|
910
|
+
resolvedCc = [...(resolvedCc ?? []), ...cc]
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const refs = [lastMsg.references, lastMsg.messageId].filter(Boolean).join(' ')
|
|
914
|
+
|
|
915
|
+
const raw = this.buildMimeMessage({
|
|
916
|
+
to,
|
|
917
|
+
subject: lastMsg.subject.startsWith('Re:') ? lastMsg.subject : `Re: ${lastMsg.subject}`,
|
|
918
|
+
body,
|
|
919
|
+
cc: resolvedCc,
|
|
920
|
+
inReplyTo: lastMsg.messageId,
|
|
921
|
+
references: refs || undefined,
|
|
922
|
+
fromEmail,
|
|
923
|
+
})
|
|
924
|
+
|
|
925
|
+
const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
|
|
926
|
+
withRetry(() =>
|
|
927
|
+
this.gmail.users.drafts.create({
|
|
928
|
+
userId: 'me',
|
|
929
|
+
requestBody: {
|
|
930
|
+
message: { raw, threadId },
|
|
931
|
+
},
|
|
932
|
+
}),
|
|
933
|
+
),
|
|
934
|
+
)
|
|
935
|
+
if (res instanceof Error) return res
|
|
936
|
+
|
|
937
|
+
return res.data
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Create a draft forwarding a thread. Reuses the same forwarded-message body
|
|
942
|
+
* building logic as forwardThread(), but saves as a draft instead of sending.
|
|
943
|
+
*/
|
|
944
|
+
async createDraftForward({
|
|
945
|
+
threadId,
|
|
946
|
+
to,
|
|
947
|
+
body,
|
|
948
|
+
fromEmail,
|
|
949
|
+
}: {
|
|
950
|
+
threadId: string
|
|
951
|
+
to: Array<{ email: string }>
|
|
952
|
+
body?: string
|
|
953
|
+
fromEmail?: string
|
|
954
|
+
}): Promise<EmptyThreadError | AuthError | ApiError | gmail_v1.Schema$Draft> {
|
|
955
|
+
const { parsed: thread } = await this.getThread({ threadId })
|
|
956
|
+
if (thread.messages.length === 0) {
|
|
957
|
+
return new EmptyThreadError({ threadId })
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const lastMsg = thread.messages[thread.messages.length - 1]!
|
|
961
|
+
const renderedBody = renderEmailBody(lastMsg.body, lastMsg.mimeType)
|
|
962
|
+
|
|
963
|
+
const fromStr = lastMsg.from.name && lastMsg.from.name !== lastMsg.from.email
|
|
964
|
+
? `${lastMsg.from.name} <${lastMsg.from.email}>`
|
|
965
|
+
: lastMsg.from.email
|
|
966
|
+
|
|
967
|
+
const fullBody = [
|
|
968
|
+
body ?? '',
|
|
969
|
+
'',
|
|
970
|
+
'---------- Forwarded message ----------',
|
|
971
|
+
`From: ${fromStr}`,
|
|
972
|
+
`Date: ${lastMsg.date}`,
|
|
973
|
+
`Subject: ${lastMsg.subject}`,
|
|
974
|
+
`To: ${lastMsg.to.map((t) => t.email).join(', ')}`,
|
|
975
|
+
'',
|
|
976
|
+
renderedBody,
|
|
977
|
+
].join('\n')
|
|
978
|
+
|
|
979
|
+
const raw = this.buildMimeMessage({
|
|
980
|
+
to,
|
|
981
|
+
subject: `Fwd: ${lastMsg.subject}`,
|
|
982
|
+
body: fullBody,
|
|
983
|
+
fromEmail,
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
|
|
987
|
+
withRetry(() =>
|
|
988
|
+
this.gmail.users.drafts.create({
|
|
989
|
+
userId: 'me',
|
|
990
|
+
requestBody: {
|
|
991
|
+
message: { raw, threadId },
|
|
992
|
+
},
|
|
993
|
+
}),
|
|
994
|
+
),
|
|
995
|
+
)
|
|
996
|
+
if (res instanceof Error) return res
|
|
997
|
+
|
|
998
|
+
return res.data
|
|
999
|
+
}
|
|
1000
|
+
|
|
819
1001
|
// =========================================================================
|
|
820
1002
|
// Label mutations (read/unread, star, archive, trash, labels)
|
|
821
1003
|
// =========================================================================
|
|
@@ -1332,6 +1514,7 @@ export class GmailClient {
|
|
|
1332
1514
|
inReplyTo: getHeader('in-reply-to') ?? undefined,
|
|
1333
1515
|
references: getHeader('references') ?? undefined,
|
|
1334
1516
|
listUnsubscribe: getHeader('list-unsubscribe') ?? undefined,
|
|
1517
|
+
listUnsubscribePost: getHeader('list-unsubscribe-post') ?? undefined,
|
|
1335
1518
|
body,
|
|
1336
1519
|
mimeType,
|
|
1337
1520
|
textBody,
|
|
@@ -1397,8 +1580,11 @@ export class GmailClient {
|
|
|
1397
1580
|
// Check if any message has attachments (non-inline)
|
|
1398
1581
|
const hasAttachments = messages.some((m) => this.hasNonInlineAttachments(m.payload))
|
|
1399
1582
|
|
|
1400
|
-
// List-Unsubscribe from latest message
|
|
1583
|
+
// List-Unsubscribe / List-Unsubscribe-Post from latest message (RFC 2369 + RFC 8058).
|
|
1584
|
+
// Used by `mail list` to surface can_unsubscribe / one_click booleans and
|
|
1585
|
+
// by `mail unsubscribe` to plan the unsubscribe flow without re-fetching.
|
|
1401
1586
|
const listUnsubscribe = getHeader('list-unsubscribe') ?? null
|
|
1587
|
+
const listUnsubscribePost = getHeader('list-unsubscribe-post') ?? null
|
|
1402
1588
|
|
|
1403
1589
|
return {
|
|
1404
1590
|
id: raw.id ?? '',
|
|
@@ -1418,6 +1604,7 @@ export class GmailClient {
|
|
|
1418
1604
|
inReplyTo,
|
|
1419
1605
|
hasAttachments,
|
|
1420
1606
|
listUnsubscribe,
|
|
1607
|
+
listUnsubscribePost,
|
|
1421
1608
|
}
|
|
1422
1609
|
}
|
|
1423
1610
|
|
package/src/imap-smtp-client.ts
CHANGED
|
@@ -382,7 +382,11 @@ export class ImapSmtpClient {
|
|
|
382
382
|
messageCount: 1,
|
|
383
383
|
inReplyTo: env.inReplyTo ?? null,
|
|
384
384
|
hasAttachments: this.hasAttachments(msg),
|
|
385
|
+
// IMAP list view uses envelope-only fetch, so raw headers aren't
|
|
386
|
+
// available. List-Unsubscribe stays null in list mode; it's
|
|
387
|
+
// resolved during getThread() where `source: true` is fetched.
|
|
385
388
|
listUnsubscribe: null,
|
|
389
|
+
listUnsubscribePost: null,
|
|
386
390
|
})
|
|
387
391
|
}
|
|
388
392
|
}
|
|
@@ -1086,9 +1090,9 @@ export class ImapSmtpClient {
|
|
|
1086
1090
|
return {
|
|
1087
1091
|
id: draftId,
|
|
1088
1092
|
message: msg,
|
|
1089
|
-
to: msg.to
|
|
1090
|
-
cc:
|
|
1091
|
-
bcc: msg.bcc
|
|
1093
|
+
to: msg.to,
|
|
1094
|
+
cc: msg.cc ?? [],
|
|
1095
|
+
bcc: msg.bcc,
|
|
1092
1096
|
}
|
|
1093
1097
|
}
|
|
1094
1098
|
|
|
@@ -1097,11 +1101,11 @@ export class ImapSmtpClient {
|
|
|
1097
1101
|
const draft = await this.getDraft({ draftId })
|
|
1098
1102
|
|
|
1099
1103
|
const result = await this.sendMessage({
|
|
1100
|
-
to: draft.to
|
|
1104
|
+
to: draft.to,
|
|
1101
1105
|
subject: draft.message.subject,
|
|
1102
1106
|
body: draft.message.body,
|
|
1103
|
-
cc: draft.cc.length > 0 ? draft.cc
|
|
1104
|
-
bcc: draft.bcc.length > 0 ? draft.bcc
|
|
1107
|
+
cc: draft.cc.length > 0 ? draft.cc : undefined,
|
|
1108
|
+
bcc: draft.bcc.length > 0 ? draft.bcc : undefined,
|
|
1105
1109
|
})
|
|
1106
1110
|
if (result instanceof Error) return result
|
|
1107
1111
|
|
|
@@ -1124,6 +1128,168 @@ export class ImapSmtpClient {
|
|
|
1124
1128
|
})
|
|
1125
1129
|
}
|
|
1126
1130
|
|
|
1131
|
+
/**
|
|
1132
|
+
* Update an existing draft. IMAP has no native update — we delete the old
|
|
1133
|
+
* draft and APPEND a new message to the Drafts folder.
|
|
1134
|
+
*/
|
|
1135
|
+
async updateDraft({
|
|
1136
|
+
draftId,
|
|
1137
|
+
to,
|
|
1138
|
+
subject,
|
|
1139
|
+
body,
|
|
1140
|
+
cc,
|
|
1141
|
+
bcc,
|
|
1142
|
+
fromEmail,
|
|
1143
|
+
}: {
|
|
1144
|
+
draftId: string
|
|
1145
|
+
to: Array<{ name?: string; email: string }>
|
|
1146
|
+
subject: string
|
|
1147
|
+
body: string
|
|
1148
|
+
cc?: Array<{ name?: string; email: string }>
|
|
1149
|
+
bcc?: Array<{ name?: string; email: string }>
|
|
1150
|
+
threadId?: string
|
|
1151
|
+
fromEmail?: string
|
|
1152
|
+
attachments?: Array<{ filename: string; mimeType: string; content: Buffer }>
|
|
1153
|
+
}) {
|
|
1154
|
+
// Delete old draft first — check for errors before creating replacement
|
|
1155
|
+
const deleted = await this.deleteDraft({ draftId })
|
|
1156
|
+
if (deleted instanceof Error) return deleted
|
|
1157
|
+
|
|
1158
|
+
// Create new draft with updated content
|
|
1159
|
+
return this.createDraft({ to, subject, body, cc, bcc, fromEmail })
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Create a draft reply to a thread. Resolves reply-to, reply-all CCs,
|
|
1164
|
+
* and sets In-Reply-To/References headers, then appends to Drafts.
|
|
1165
|
+
*/
|
|
1166
|
+
async createDraftReply({
|
|
1167
|
+
threadId,
|
|
1168
|
+
body,
|
|
1169
|
+
replyAll = false,
|
|
1170
|
+
cc,
|
|
1171
|
+
fromEmail,
|
|
1172
|
+
}: {
|
|
1173
|
+
threadId: string
|
|
1174
|
+
body: string
|
|
1175
|
+
replyAll?: boolean
|
|
1176
|
+
cc?: Array<{ email: string }>
|
|
1177
|
+
fromEmail?: string
|
|
1178
|
+
}): Promise<EmptyThreadError | AuthError | ApiError | { id: string; message: { id: string }; threadId: string }> {
|
|
1179
|
+
const thread = await this.getThread({ threadId })
|
|
1180
|
+
if (thread.parsed.messages.length === 0) {
|
|
1181
|
+
return new EmptyThreadError({ threadId })
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const lastMsg = thread.parsed.messages[thread.parsed.messages.length - 1]!
|
|
1185
|
+
const replyTo = lastMsg.replyTo ?? lastMsg.from.email
|
|
1186
|
+
const to = [{ email: replyTo }]
|
|
1187
|
+
|
|
1188
|
+
let resolvedCc: Array<{ email: string }> | undefined
|
|
1189
|
+
if (replyAll) {
|
|
1190
|
+
const myEmail = this.account.email.toLowerCase()
|
|
1191
|
+
const allRecipients = [
|
|
1192
|
+
...lastMsg.to.map((r) => r.email),
|
|
1193
|
+
...(lastMsg.cc?.map((r) => r.email) ?? []),
|
|
1194
|
+
]
|
|
1195
|
+
.filter((e) => e.toLowerCase() !== myEmail)
|
|
1196
|
+
.filter((e) => e.toLowerCase() !== replyTo.toLowerCase())
|
|
1197
|
+
|
|
1198
|
+
if (allRecipients.length > 0) {
|
|
1199
|
+
resolvedCc = allRecipients.map((e) => ({ email: e }))
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
if (cc) {
|
|
1204
|
+
resolvedCc = [...(resolvedCc ?? []), ...cc]
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const refs = [lastMsg.references, lastMsg.messageId].filter(Boolean).join(' ')
|
|
1208
|
+
const subject = lastMsg.subject.startsWith('Re:') ? lastMsg.subject : `Re: ${lastMsg.subject}`
|
|
1209
|
+
|
|
1210
|
+
// Build MIME with reply headers
|
|
1211
|
+
const headers = [
|
|
1212
|
+
`From: ${fromEmail ?? this.account.email}`,
|
|
1213
|
+
`To: ${to.map((r) => r.email).join(', ')}`,
|
|
1214
|
+
`Subject: ${subject}`,
|
|
1215
|
+
`Date: ${new Date().toUTCString()}`,
|
|
1216
|
+
`MIME-Version: 1.0`,
|
|
1217
|
+
`Content-Type: text/plain; charset=utf-8`,
|
|
1218
|
+
]
|
|
1219
|
+
if (resolvedCc && resolvedCc.length > 0) {
|
|
1220
|
+
headers.push(`Cc: ${resolvedCc.map((r) => r.email).join(', ')}`)
|
|
1221
|
+
}
|
|
1222
|
+
if (lastMsg.messageId) {
|
|
1223
|
+
headers.push(`In-Reply-To: ${lastMsg.messageId}`)
|
|
1224
|
+
}
|
|
1225
|
+
if (refs) {
|
|
1226
|
+
headers.push(`References: ${refs}`)
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const raw = headers.join('\r\n') + '\r\n\r\n' + body
|
|
1230
|
+
const rawBuffer = Buffer.from(raw)
|
|
1231
|
+
|
|
1232
|
+
const result = await this.withImap(async (client) => {
|
|
1233
|
+
const draftsPath = await this.resolveMailboxPath(client, 'drafts')
|
|
1234
|
+
const appendResult = await client.append(draftsPath, rawBuffer, ['\\Draft', '\\Seen'])
|
|
1235
|
+
const uid = appendResult && typeof appendResult === 'object' && 'uid' in appendResult ? appendResult.uid : undefined
|
|
1236
|
+
return {
|
|
1237
|
+
id: uid ? makeThreadId(draftsPath, uid) : 'unknown',
|
|
1238
|
+
message: { id: 'unknown' },
|
|
1239
|
+
threadId: uid ? makeThreadId(draftsPath, uid) : 'unknown',
|
|
1240
|
+
}
|
|
1241
|
+
})
|
|
1242
|
+
if (result instanceof Error) return result
|
|
1243
|
+
return result
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* Create a draft forwarding a thread. Builds the forwarded-message body
|
|
1248
|
+
* and appends to Drafts folder.
|
|
1249
|
+
*/
|
|
1250
|
+
async createDraftForward({
|
|
1251
|
+
threadId,
|
|
1252
|
+
to,
|
|
1253
|
+
body,
|
|
1254
|
+
fromEmail,
|
|
1255
|
+
}: {
|
|
1256
|
+
threadId: string
|
|
1257
|
+
to: Array<{ email: string }>
|
|
1258
|
+
body?: string
|
|
1259
|
+
fromEmail?: string
|
|
1260
|
+
}): Promise<EmptyThreadError | AuthError | ApiError | { id: string; message: { id: string }; threadId: string }> {
|
|
1261
|
+
const thread = await this.getThread({ threadId })
|
|
1262
|
+
if (thread.parsed.messages.length === 0) {
|
|
1263
|
+
return new EmptyThreadError({ threadId })
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const lastMsg = thread.parsed.messages[thread.parsed.messages.length - 1]!
|
|
1267
|
+
const renderedBody = renderEmailBody(lastMsg.body, lastMsg.mimeType)
|
|
1268
|
+
|
|
1269
|
+
const fromStr = lastMsg.from.name && lastMsg.from.name !== lastMsg.from.email
|
|
1270
|
+
? `${lastMsg.from.name} <${lastMsg.from.email}>`
|
|
1271
|
+
: lastMsg.from.email
|
|
1272
|
+
|
|
1273
|
+
const fullBody = [
|
|
1274
|
+
body ?? '',
|
|
1275
|
+
'',
|
|
1276
|
+
'---------- Forwarded message ----------',
|
|
1277
|
+
`From: ${fromStr}`,
|
|
1278
|
+
`Date: ${lastMsg.date}`,
|
|
1279
|
+
`Subject: ${lastMsg.subject}`,
|
|
1280
|
+
`To: ${lastMsg.to.map((t) => t.email).join(', ')}`,
|
|
1281
|
+
'',
|
|
1282
|
+
renderedBody,
|
|
1283
|
+
].join('\n')
|
|
1284
|
+
|
|
1285
|
+
return this.createDraft({
|
|
1286
|
+
to,
|
|
1287
|
+
subject: `Fwd: ${lastMsg.subject}`,
|
|
1288
|
+
body: fullBody,
|
|
1289
|
+
fromEmail,
|
|
1290
|
+
})
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1127
1293
|
// =========================================================================
|
|
1128
1294
|
// Folder listing (IMAP equivalent of labels)
|
|
1129
1295
|
// =========================================================================
|
|
@@ -1207,6 +1373,8 @@ export class ImapSmtpClient {
|
|
|
1207
1373
|
let body = ''
|
|
1208
1374
|
let mimeType = 'text/plain'
|
|
1209
1375
|
let textBody: string | null = null
|
|
1376
|
+
let listUnsubscribe: string | undefined
|
|
1377
|
+
let listUnsubscribePost: string | undefined
|
|
1210
1378
|
|
|
1211
1379
|
if (msg.source) {
|
|
1212
1380
|
const source = msg.source.toString('utf-8')
|
|
@@ -1214,6 +1382,16 @@ export class ImapSmtpClient {
|
|
|
1214
1382
|
body = bodyResult.body
|
|
1215
1383
|
mimeType = bodyResult.mimeType
|
|
1216
1384
|
textBody = bodyResult.textBody
|
|
1385
|
+
|
|
1386
|
+
// Extract List-Unsubscribe / List-Unsubscribe-Post from raw MIME headers.
|
|
1387
|
+
// envelope-based fetches don't surface these, but getThread always fetches
|
|
1388
|
+
// source so it's available by the time we parse a full message.
|
|
1389
|
+
const headerEnd = source.indexOf('\r\n\r\n')
|
|
1390
|
+
const altEnd = source.indexOf('\n\n')
|
|
1391
|
+
const headerSplit = headerEnd !== -1 ? headerEnd : altEnd
|
|
1392
|
+
const headerText = headerSplit === -1 ? source : source.slice(0, headerSplit)
|
|
1393
|
+
listUnsubscribe = this.getHeader(headerText, 'list-unsubscribe')
|
|
1394
|
+
listUnsubscribePost = this.getHeader(headerText, 'list-unsubscribe-post')
|
|
1217
1395
|
}
|
|
1218
1396
|
|
|
1219
1397
|
// Extract attachments from bodyStructure
|
|
@@ -1240,7 +1418,8 @@ export class ImapSmtpClient {
|
|
|
1240
1418
|
messageId: env.messageId ?? '',
|
|
1241
1419
|
inReplyTo: env.inReplyTo,
|
|
1242
1420
|
references: undefined,
|
|
1243
|
-
listUnsubscribe
|
|
1421
|
+
listUnsubscribe,
|
|
1422
|
+
listUnsubscribePost,
|
|
1244
1423
|
body,
|
|
1245
1424
|
mimeType,
|
|
1246
1425
|
textBody,
|