@postalsys/email-text-tools 1.3.2 → 1.4.1

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/.ncurc.js CHANGED
@@ -2,7 +2,6 @@ module.exports = {
2
2
  upgrade: true,
3
3
  reject: [
4
4
  // Block package upgrades that moved to ESM
5
- 'node-fetch',
6
5
  'nanoid'
7
6
  ]
8
7
  };
package/examples/risk.js CHANGED
@@ -21,7 +21,12 @@ async function analyzeEmail(path) {
21
21
  html: parsed.html,
22
22
  text: parsed.text
23
23
  },
24
- process.env.OPENAI_API_KEY
24
+ process.env.OPENAI_API_KEY,
25
+ {
26
+ //gptModel: 'gpt-3.5-turbo'
27
+ gptModel: 'gpt-4',
28
+ maxTokens: 6000
29
+ }
25
30
  );
26
31
 
27
32
  result.file = Path.basename(path);
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs').promises;
4
4
  const generateSummary = require('../lib/generate-summary');
5
5
  const simpleParser = require('mailparser').simpleParser;
6
+ const libmime = require('libmime');
6
7
 
7
8
  async function main() {
8
9
  const eml = await fs.readFile(process.argv[2]);
@@ -11,12 +12,17 @@ async function main() {
11
12
 
12
13
  const summary = await generateSummary(
13
14
  {
14
- from: parsed.from.value,
15
- subject: parsed.subject,
15
+ headers: parsed.headerLines.map(header => libmime.decodeHeader(header.line)),
16
+ attachments: parsed.attachments,
16
17
  html: parsed.html,
17
18
  text: parsed.text
18
19
  },
19
- process.env.OPENAI_API_KEY
20
+ process.env.OPENAI_API_KEY,
21
+ {
22
+ //gptModel: 'gpt-3.5-turbo'
23
+ gptModel: 'gpt-4',
24
+ maxTokens: 6000
25
+ }
20
26
  );
21
27
 
22
28
  console.log(summary);
@@ -2,10 +2,15 @@
2
2
 
3
3
  const packageData = require('../package.json');
4
4
  const htmlToText = require('../lib/html-to-text');
5
- const nodeFetch = require('node-fetch');
6
- const fetchCmd = global.fetch || nodeFetch;
5
+ const GPT3Tokenizer = require('gpt3-tokenizer').default;
7
6
 
8
- const MAX_TEXT_LENGTH = 4 * 1024;
7
+ const { fetch: fetchCmd, Agent } = require('undici');
8
+ const fetchAgent = new Agent({ connect: { timeout: 90 * 1000 } });
9
+
10
+ const tokenizer = new GPT3Tokenizer({ type: 'gpt3' });
11
+
12
+ const MAX_ALLOWED_TEXT_LENGTH = 32 * 1024;
13
+ const MAX_ALLOWED_TOKENS = 4000;
9
14
  const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions';
10
15
 
11
16
  const SYSTEM_PROMPT = `
@@ -16,38 +21,89 @@ You do not comment or explain anything.
16
21
  .trim();
17
22
 
18
23
  const USER_PROMPT = `
19
- Describe the sentiment of the email using one word. Use either "positive", "neutral" or "negative". Prefix the sentiment with "Sentiment:".
20
- On a separate line, write a one-sentence summary of the email. Prefix the summary with "Summary:".
21
- I want you to only reply with the sentiment and summary, do not write explanations.
22
- All the text that follows from now on is the email message:
23
- `
24
- .replace(/\s+/g, ' ')
25
- .trim();
26
-
27
- async function generateSummary(message, apiToken) {
28
- let text = message.text;
29
- if (message.html && (!text || message.html.length > text.length * 10)) {
24
+ Instructions:
25
+ - You are an executive assistant scanning incoming emails to inform your superiors about what is important and what is not.
26
+ - Your task is to monitor and analyze incoming emails, which consist of the message headers, a list of attachments, and text content
27
+ - Describe the sentiment of the email using one word. Use either "positive", "neutral" or "negative". Include this value in the response as a "sentiment" property.
28
+ - Generate a one-sentence summary of the email. Include this value in the response as a "summary" property.
29
+ - Does it seem like the sender of the email would expect a reply to this email? Include this information in the response as a "shouldReply" property with the value "true" if they expect it and "false" if not.
30
+ - If this email is a reply to a previous email, then extract the text content that only the email's sender wrote, and include this as a "replyText" property in the response.
31
+ - Do not include message signatures in the extracted reply text
32
+ - Respond with a JSON formatted structure. Do not write any other explanations.
33
+
34
+ Facts:
35
+ - An email consists of message headers, an attachments list, and text content
36
+ - The email to analyze is formatted in a JSON format
37
+ - An email is a reply to a previous email only if it includes a "in-reply-to" header
38
+ - The email structure includes a property "headers" that contains an array of header values.
39
+ - Each header contains two properties, "key" as the header field key name and "value" as the header value without the key prefix
40
+ - The email structure includes a property "attachments" that contains an array of attachments.
41
+ - Each attachment includes a "filename" property that describes the file name, a "contentType" property that describes the Content-Type value of the attachment
42
+ - The email includes a "text" property for the text content
43
+
44
+ Analyze the following email:
45
+ `.trim();
46
+
47
+ async function generateSummary(message, apiToken, opts) {
48
+ opts = opts || {};
49
+
50
+ let maxAllowedTokens = opts.maxTokens || MAX_ALLOWED_TOKENS;
51
+ let gptModel = opts.gptModel || 'gpt-3.5-turbo';
52
+
53
+ let text = message.text || '';
54
+ if (message.html && (!text || message.html.length > text.length * 2)) {
30
55
  text = htmlToText(message.html);
31
56
  }
32
57
 
33
- text = text.trim();
34
- if (text.length > MAX_TEXT_LENGTH) {
35
- text = text.substring(0, MAX_TEXT_LENGTH);
36
- }
58
+ let prompt;
37
59
 
38
- let header = [];
39
- if (message.subject) {
40
- header.push(`Subject: ${message.subject}`);
41
- }
42
- if (message.from) {
43
- let from = [].concat(message.from || []).shift();
44
- header.push(`From: ${from.name || from.address}`);
60
+ const content = {
61
+ headers: [].concat(message.headers || []).filter(
62
+ // use a whitelist
63
+ header => ['from', 'to', 'cc', 'bcc', 'subject', 'mime-version', 'date', 'content-type', 'list-id'].includes(header.key)
64
+ ),
65
+ attachments: [].concat(message.attachments || []).map(attachment => ({ filename: attachment.filename, contentType: attachment.contentType }))
66
+ };
67
+
68
+ let charactersRemoved = 0;
69
+ let promptText = text;
70
+
71
+ if (promptText.length > MAX_ALLOWED_TEXT_LENGTH) {
72
+ charactersRemoved += promptText.length - MAX_ALLOWED_TEXT_LENGTH;
73
+ promptText = promptText.substr(0, MAX_ALLOWED_TEXT_LENGTH);
45
74
  }
46
75
 
47
- if (header.length) {
48
- text = `${header.join('\n')}
76
+ while (promptText.length) {
77
+ content.text = promptText;
78
+ prompt = `${USER_PROMPT}
49
79
 
50
- ${text}`;
80
+ ${JSON.stringify(content)}`;
81
+
82
+ let tokens = tokenizer.encode(prompt);
83
+ if (tokens.text.length <= maxAllowedTokens) {
84
+ break;
85
+ }
86
+ if (promptText.length > 2 * 1024 * 1024) {
87
+ promptText = promptText.substring(0, promptText.length - 1024 * 1024);
88
+ charactersRemoved += 1024 * 1024;
89
+ } else if (promptText.length > 2 * 1024) {
90
+ promptText = promptText.substring(0, promptText.length - 1024);
91
+ charactersRemoved += 1024;
92
+ } else if (promptText.length > 2 * 256) {
93
+ promptText = promptText.substring(0, promptText.length - 256);
94
+ charactersRemoved += 255;
95
+ } else if (promptText.length > 2 * 100) {
96
+ promptText = promptText.substring(0, promptText.length - 100);
97
+ charactersRemoved += 100;
98
+ } else if (promptText.length > 2 * 10) {
99
+ promptText = promptText.substring(0, promptText.length - 10);
100
+ charactersRemoved += 10;
101
+ } else if (promptText.length > 1) {
102
+ promptText = promptText.substring(0, promptText.length - 1);
103
+ charactersRemoved += 1;
104
+ } else {
105
+ throw new Error(`Prompt too long. Removed ${charactersRemoved} characters.`);
106
+ }
51
107
  }
52
108
 
53
109
  let headers = {
@@ -57,7 +113,7 @@ ${text}`;
57
113
  };
58
114
 
59
115
  let payload = {
60
- model: 'gpt-3.5-turbo',
116
+ model: gptModel,
61
117
  messages: [
62
118
  {
63
119
  role: 'system',
@@ -65,42 +121,58 @@ ${text}`;
65
121
  },
66
122
  {
67
123
  role: 'user',
68
- content: `${USER_PROMPT}
69
-
70
- ${text}`
124
+ content: prompt
71
125
  }
72
126
  ]
73
127
  };
74
128
 
75
- let res = await fetchCmd(OPENAI_API_URL, {
76
- method: 'post',
77
- headers,
78
- body: JSON.stringify(payload)
79
- });
129
+ let res;
130
+ let data;
131
+ let retries = 0;
132
+
133
+ let run = async () => {
134
+ res = await fetchCmd(OPENAI_API_URL, {
135
+ method: 'post',
136
+ headers,
137
+ body: JSON.stringify(payload),
138
+ dispatcher: fetchAgent
139
+ });
140
+
141
+ data = await res.json();
142
+
143
+ if (!res.ok) {
144
+ if (res.status === 429 && ++retries < 5) {
145
+ // try again
146
+ await new Promise(r => setTimeout(r, 1000));
147
+ return await run();
148
+ }
80
149
 
81
- let data = await res.json();
150
+ if (data && data.error) {
151
+ let error = new Error(data.error.message || data.error);
152
+ if (data.error.code) {
153
+ error.code = data.error.code;
154
+ }
82
155
 
83
- if (!res.ok) {
84
- if (data && data.error) {
85
- let error = new Error(data.error.message || data.error);
86
- if (data.error.code) {
87
- error.code = data.error.code;
156
+ error.statusCode = res.status;
157
+ throw error;
88
158
  }
89
159
 
160
+ let error = new Error('Failed to run API request');
90
161
  error.statusCode = res.status;
91
162
  throw error;
92
163
  }
93
164
 
94
- let error = new Error('Failed to run API request');
95
- error.statusCode = res.status;
96
- throw error;
97
- }
165
+ if (!data) {
166
+ throw new Error(`Failed to POST API request`);
167
+ }
168
+ };
98
169
 
99
- if (!data) {
100
- throw new Error(`Failed to POST API request`);
101
- }
170
+ const reqStartTime = Date.now();
171
+ await run();
172
+ const reqEndTime = Date.now();
102
173
 
103
- let output =
174
+ let values;
175
+ const output =
104
176
  data &&
105
177
  data.choices &&
106
178
  data.choices
@@ -112,25 +184,29 @@ ${text}`
112
184
  .replace(/^\s*"|"\s*$/g, '')
113
185
  .trim();
114
186
 
115
- let structured = {};
116
-
117
- let summaryMatch = output.match(/\bsummary:\s*(.*)$/im);
118
- if (summaryMatch) {
119
- structured.summary = summaryMatch[1].replace(/^["'\s]+|["'\s]+$/g, '');
187
+ try {
188
+ values = JSON.parse(output);
189
+ if (typeof values.shouldReply === 'string') {
190
+ values.shouldReply = values.shouldReply === 'true';
191
+ }
192
+ if (typeof values.shouldReply !== 'undefined' && typeof values.shouldReply !== 'boolean') {
193
+ delete values.shouldReply;
194
+ }
195
+ } catch (err) {
196
+ let error = new Error('Failed to parse output from OpenAI API');
197
+ error.textContent = output;
198
+ throw error;
120
199
  }
121
200
 
122
- let sentimentMatch = output.match(/\bsentiment:\s*(.*)$/im);
123
- if (sentimentMatch) {
124
- structured.sentiment = sentimentMatch[1].replace(/^["'\s]+|["'\s]+$/g, '').toLowerCase();
125
- }
201
+ const response = Object.assign({ id: null, tokens: null, model: null }, values, {
202
+ id: data && data.id,
203
+ tokens: data && data.usage && data.usage.total_tokens,
204
+ model: gptModel,
126
205
 
127
- const response = Object.assign(
128
- {
129
- id: data && data.id,
130
- tokens: data && data.usage && data.usage.total_tokens
131
- },
132
- structured
133
- );
206
+ _text: content.text,
207
+ _time: reqEndTime - reqStartTime,
208
+ _cr: charactersRemoved
209
+ });
134
210
 
135
211
  return response;
136
212
  }
@@ -2,9 +2,10 @@
2
2
 
3
3
  const packageData = require('../package.json');
4
4
  const htmlToText = require('../lib/html-to-text');
5
- const nodeFetch = require('node-fetch');
6
5
  const GPT3Tokenizer = require('gpt3-tokenizer').default;
7
- const fetchCmd = global.fetch || nodeFetch;
6
+
7
+ const { fetch: fetchCmd, Agent } = require('undici');
8
+ const fetchAgent = new Agent({ connect: { timeout: 90 * 1000 } });
8
9
 
9
10
  const tokenizer = new GPT3Tokenizer({ type: 'gpt3' });
10
11
 
@@ -60,7 +61,12 @@ Facts:
60
61
  Analyze the following email:
61
62
  `.trim();
62
63
 
63
- async function riskAnalysis(message, apiToken) {
64
+ async function riskAnalysis(message, apiToken, opts) {
65
+ opts = opts || {};
66
+
67
+ let maxAllowedTokens = opts.maxTokens || MAX_ALLOWED_TOKENS;
68
+ let gptModel = opts.gptModel || 'gpt-3.5-turbo';
69
+
64
70
  let text = message.text || '';
65
71
  if (message.html && (!text || message.html.length > text.length * 2)) {
66
72
  text = htmlToText(message.html);
@@ -105,7 +111,7 @@ async function riskAnalysis(message, apiToken) {
105
111
  ${JSON.stringify(content)}`;
106
112
 
107
113
  let tokens = tokenizer.encode(prompt);
108
- if (tokens.text.length <= MAX_ALLOWED_TOKENS) {
114
+ if (tokens.text.length <= maxAllowedTokens) {
109
115
  break;
110
116
  }
111
117
  if (promptText.length > 2 * 1024 * 1024) {
@@ -138,7 +144,7 @@ ${JSON.stringify(content)}`;
138
144
  };
139
145
 
140
146
  let payload = {
141
- model: 'gpt-3.5-turbo',
147
+ model: gptModel,
142
148
  messages: [
143
149
  {
144
150
  role: 'system',
@@ -151,37 +157,50 @@ ${JSON.stringify(content)}`;
151
157
  ]
152
158
  };
153
159
 
154
- const reqStartTime = Date.now();
155
-
156
- let res = await fetchCmd(OPENAI_API_URL, {
157
- method: 'post',
158
- headers,
159
- body: JSON.stringify(payload)
160
- });
161
-
162
- const reqEndTime = Date.now();
160
+ let res;
161
+ let data;
162
+ let retries = 0;
163
+
164
+ let run = async () => {
165
+ res = await fetchCmd(OPENAI_API_URL, {
166
+ method: 'post',
167
+ headers,
168
+ body: JSON.stringify(payload),
169
+ dispatcher: fetchAgent
170
+ });
171
+
172
+ data = await res.json();
173
+
174
+ if (!res.ok) {
175
+ if (res.status === 429 && ++retries < 5) {
176
+ // try again
177
+ await new Promise(r => setTimeout(r, 1000));
178
+ return await run();
179
+ }
163
180
 
164
- let data = await res.json();
181
+ if (data && data.error) {
182
+ let error = new Error(data.error.message || data.error);
183
+ if (data.error.code) {
184
+ error.code = data.error.code;
185
+ }
165
186
 
166
- if (!res.ok) {
167
- if (data && data.error) {
168
- let error = new Error(data.error.message || data.error);
169
- if (data.error.code) {
170
- error.code = data.error.code;
187
+ error.statusCode = res.status;
188
+ throw error;
171
189
  }
172
190
 
191
+ let error = new Error('Failed to run API request');
173
192
  error.statusCode = res.status;
174
193
  throw error;
175
194
  }
176
195
 
177
- let error = new Error('Failed to run API request');
178
- error.statusCode = res.status;
179
- throw error;
180
- }
196
+ if (!data) {
197
+ throw new Error(`Failed to POST API request`);
198
+ }
199
+ };
181
200
 
182
- if (!data) {
183
- throw new Error(`Failed to POST API request`);
184
- }
201
+ const reqStartTime = Date.now();
202
+ await run();
203
+ const reqEndTime = Date.now();
185
204
 
186
205
  let values;
187
206
  const output =
@@ -205,9 +224,10 @@ ${JSON.stringify(content)}`;
205
224
  throw error;
206
225
  }
207
226
 
208
- const response = Object.assign({ id: null, tokens: null }, values, {
227
+ const response = Object.assign({ id: null, tokens: null, model: null }, values, {
209
228
  id: data && data.id,
210
229
  tokens: data && data.usage && data.usage.total_tokens,
230
+ model: gptModel,
211
231
 
212
232
  _text: content.text,
213
233
  _time: reqEndTime - reqStartTime,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postalsys/email-text-tools",
3
- "version": "1.3.2",
3
+ "version": "1.4.1",
4
4
  "description": "Email text tools",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -23,7 +23,7 @@
23
23
  "mailparser": "3.6.4"
24
24
  },
25
25
  "dependencies": {
26
- "dompurify": "3.0.1",
26
+ "dompurify": "3.0.2",
27
27
  "gpt3-tokenizer": "1.1.5",
28
28
  "handlebars": "4.7.7",
29
29
  "he": "1.2.0",
@@ -33,8 +33,8 @@
33
33
  "juice": "9.0.0",
34
34
  "linkify-it": "4.0.1",
35
35
  "moment-timezone": "0.5.43",
36
- "node-fetch": "2.6.7",
37
36
  "node-html-parser": "6.1.5",
38
- "tlds": "1.238.0"
37
+ "tlds": "1.238.0",
38
+ "undici": "5.22.0"
39
39
  }
40
40
  }