Skip to content

Commit 6565849

Browse files
authored
Merge pull request #8 from icdocsoc/feat-imap-drafts
Feat: Add uploading of draft emails
2 parents 573fe7f + c319d40 commit 6565849

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+1086
-156
lines changed

common/util/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"build": "tsc --build --verbose",
1111
"build-local": "tsc --build --verbose"
1212
},
13-
"type": "commonjs",
13+
"type": "module",
1414
"main": "./dist/index.js",
1515
"typings": "./dist/index.d.ts"
1616
}

common/util/src/files.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { stopIfCriticalFsError } from "./files";
2-
import createLogger from "./logger";
1+
import { stopIfCriticalFsError } from "./files.js";
2+
import createLogger from "./logger.js";
33

44
jest.mock("./logger", () => {
55
const logger = {

common/util/src/files.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import createLogger from "./logger";
1+
import createLogger from "./logger.js";
22

33
const logger = createLogger("docsoc.utils");
44

common/util/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
export * from "./files";
2-
export { default as createLogger } from "./logger";
1+
export * from "./files.js";
2+
export { default as createLogger } from "./logger.js";

common/util/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"extends": "../../tsconfig.base.json",
33
"compilerOptions": {
4-
"module": "commonjs",
4+
"module": "esnext",
55
"forceConsistentCasingInFileNames": true,
66
"strict": true,
77
"noImplicitOverride": true,

email/libmailmerge/.eslintrc.json

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,42 @@
11
{
2-
"extends": ["../../.eslintrc.json"],
3-
"ignorePatterns": ["!**/*"],
2+
"extends": [
3+
"../../.eslintrc.json"
4+
],
5+
"ignorePatterns": [
6+
"!**/*"
7+
],
48
"overrides": [
59
{
6-
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
10+
"files": [
11+
"*.ts",
12+
"*.tsx",
13+
"*.js",
14+
"*.jsx"
15+
],
716
"rules": {}
817
},
918
{
10-
"files": ["*.ts", "*.tsx"],
19+
"files": [
20+
"*.ts",
21+
"*.tsx"
22+
],
1123
"rules": {}
1224
},
1325
{
14-
"files": ["*.js", "*.jsx"],
26+
"files": [
27+
"*.js",
28+
"*.jsx"
29+
],
1530
"rules": {}
1631
},
1732
{
18-
"files": ["*.json"],
33+
"files": [
34+
"*.json"
35+
],
1936
"parser": "jsonc-eslint-parser",
2037
"rules": {
21-
"@nx/dependency-checks": "error"
38+
"@nx/dependency-checks": "warn"
2239
}
2340
}
2441
]
25-
}
42+
}

email/libmailmerge/package.json

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@
22
"name": "@docsoc/libmailmerge",
33
"version": "0.1.0",
44
"dependencies": {
5+
"@azure/identity": "^4.4.1",
6+
"@docsoc/util": "^0.1.0",
7+
"@microsoft/microsoft-graph-client": "^3.0.7",
8+
"chalk": "^5.3.0",
9+
"cli-progress": "^3.12.0",
510
"email-validator": "^2.0.4",
611
"html-to-text": "^9.0.5",
712
"markdown-it": "^14.1.0",
813
"nodemailer": "^6.9.14",
914
"nunjucks": "^3.2.4",
10-
"tslib": "^2.3.0",
11-
"@docsoc/util": "^0.1.0"
15+
"readline-sync": "^1.4.10",
16+
"tslib": "^2.3.0"
1217
},
1318
"scripts": {
1419
"start": "ts-node src/cli.ts",
@@ -17,17 +22,21 @@
1722
"build-local": "tsc --build --verbose"
1823
},
1924
"bin": "./dist/cli.js",
20-
"type": "commonjs",
25+
"type": "module",
2126
"main": "./dist/index.js",
2227
"typings": "./dist/index.d.ts",
2328
"private": true,
2429
"devDependencies": {
2530
"@types/chalk": "^2.2.0",
31+
"@types/cli-progress": "^3.11.6",
2632
"@types/csv-parse": "^1.2.2",
2733
"@types/dotenv": "^8.2.0",
2834
"@types/email-validator": "^1.0.6",
35+
"@types/express": "^4.17.21",
2936
"@types/html-to-text": "^9.0.4",
37+
"@types/imapflow": "^1.0.19",
3038
"@types/markdown-it": "^14.1.1",
39+
"@types/mime-types": "^2.1.4",
3140
"@types/nodemailer": "^6.4.15",
3241
"@types/nunjucks": "^3.2.6",
3342
"@types/readline-sync": "^1.4.8",
@@ -36,4 +45,4 @@
3645
"jest-mock-extended": "^3.0.7",
3746
"ts-node": "^10.9.2"
3847
}
39-
}
48+
}

email/libmailmerge/src/engines/index.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
* Contains the different templating engines mail merge supports
33
* @module engines
44
*/
5-
import nunjucksEngine from "./nunjucks-md";
6-
import { TemplateEngineConstructor } from "./types";
5+
import nunjucksEngine from "./nunjucks-md/index.js";
6+
import { TemplateEngineConstructor } from "./types.js";
77

88
export type TEMPLATE_ENGINES = "nunjucks";
99

@@ -12,6 +12,6 @@ export const ENGINES_MAP: Record<TEMPLATE_ENGINES, TemplateEngineConstructor> =
1212
nunjucks: nunjucksEngine,
1313
};
1414

15-
export * from "./types";
16-
export * from "./nunjucks-md";
17-
export { default as NunjucksMarkdownEngine } from "./nunjucks-md";
15+
export * from "./types.js";
16+
export * from "./nunjucks-md/index.js";
17+
export { default as NunjucksMarkdownEngine } from "./nunjucks-md/index.js";

email/libmailmerge/src/engines/nunjucks-md/index.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
import { promises as fs } from "fs";
22
import nunjucks from "nunjucks";
33

4-
import { renderMarkdownToHtml } from "../../markdown/toHtml";
5-
import { MappedCSVRecord } from "../../util/types";
6-
import { TemplateEngineOptions, TemplatePreviews } from "../types";
7-
import { TemplateEngine } from "../types";
8-
import getTemplateFields from "./getFields";
4+
import { renderMarkdownToHtml } from "../../markdown/toHtml.js";
5+
import { MappedCSVRecord } from "../../util/types.js";
6+
import { TemplateEngineOptions, TemplatePreviews } from "../types.js";
7+
import { TemplateEngine } from "../types.js";
8+
import getTemplateFields from "./getFields.js";
99
import {
1010
assertIsNunjucksTemplateOptions,
1111
NunjucksMarkdownTemplateOptions,
1212
NunjucksSidecarMetadata,
13-
} from "./types";
13+
} from "./types.js";
1414

15-
export { default as getNunjucksTemplateFields } from "./getFields";
16-
export * from "./types";
15+
export { default as getNunjucksTemplateFields } from "./getFields.js";
16+
export * from "./types.js";
1717

1818
/**
1919
* A Nunjucks Markdown template engine

email/libmailmerge/src/engines/nunjucks-md/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { TemplateEngineOptions } from "../types";
1+
import { TemplateEngineOptions } from "../types.js";
22

33
/**
44
* Nunjucks template options

email/libmailmerge/src/graph/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./uploadDrafts.js";
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { InteractiveBrowserCredential } from "@azure/identity";
2+
import { createLogger } from "@docsoc/util";
3+
import { Client } from "@microsoft/microsoft-graph-client";
4+
import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials/index.js";
5+
import fs from "fs/promises";
6+
import { convert } from "html-to-text";
7+
import { basename } from "path";
8+
9+
import { EmailString } from "../util/types.js";
10+
11+
const logger = createLogger("imap");
12+
13+
export interface ImapConfig {
14+
host: string;
15+
port: number;
16+
username: string;
17+
}
18+
19+
/** Docs say limit to 4MB uplod chuncks for large files */
20+
const UPLOAD_ATTACHMENT_CHUNK_SIZE = 4 * 1024 * 1024;
21+
22+
export class EmailUploader {
23+
private client?: Client;
24+
25+
public async authenticate(desiredEmail: string, tenantId?: string, clientId?: string) {
26+
logger.info("Getting OAuth token using Microsoft libraries...");
27+
28+
if (!tenantId) {
29+
throw new Error("Tenant ID not provided");
30+
}
31+
if (!clientId) {
32+
throw new Error("Client ID not provided");
33+
}
34+
35+
const credential = new InteractiveBrowserCredential({
36+
tenantId: tenantId,
37+
clientId: clientId,
38+
redirectUri: "http://localhost",
39+
});
40+
41+
// @microsoft/microsoft-graph-client/authProviders/azureTokenCredentials
42+
const authProvider = new TokenCredentialAuthenticationProvider(credential, {
43+
scopes: ["Mail.ReadWrite"],
44+
});
45+
46+
this.client = Client.initWithMiddleware({ authProvider: authProvider });
47+
48+
try {
49+
const user = await this.client.api("/me").get();
50+
if (user.mail === desiredEmail || user.userPrincipalName === desiredEmail) {
51+
logger.info(`Authenticated user email matches the provided email ${desiredEmail}.`);
52+
} else {
53+
logger.error(
54+
`Authenticated user email does not match the provided email ${desiredEmail}.`,
55+
);
56+
throw new Error(
57+
`Authenticated user email does not match the provided email ${desiredEmail}.`,
58+
);
59+
}
60+
} catch (error) {
61+
logger.error("Error fetching user profile:", error);
62+
throw error;
63+
}
64+
}
65+
66+
private async uploadFile(path: string, messageID: string) {
67+
logger.info(`Uploading file ${path}...`);
68+
if (!this.client) {
69+
throw new Error("Client not authenticated");
70+
}
71+
const fileData = await fs.readFile(path);
72+
const fileStats = await fs.stat(path);
73+
const filename = basename(path);
74+
75+
// If above 3MB
76+
if (fileStats.size > 3 * 1024 * 1024) {
77+
// 1: Create upload session
78+
const uploadSession = await this.client
79+
.api(`/me/messages/${messageID}/attachments/createUploadSession`)
80+
.post({
81+
AttachmentItem: {
82+
attachmentType: "file",
83+
name: filename,
84+
size: fileStats.size,
85+
},
86+
});
87+
const { uploadUrl, nextExpectedRanges } = uploadSession;
88+
89+
if (!uploadUrl) {
90+
throw new Error("No upload URL returned from createUploadSession.");
91+
}
92+
93+
// 2: Upload file in chunks
94+
let start = 0;
95+
let end = UPLOAD_ATTACHMENT_CHUNK_SIZE - 1;
96+
const fileSize = fileStats.size;
97+
98+
while (start < fileSize) {
99+
const chunk = fileData.slice(start, end + 1);
100+
const contentRange = `bytes ${start}-${end}/${fileSize}`;
101+
102+
const response = await fetch(uploadUrl, {
103+
method: "PUT",
104+
headers: {
105+
"Content-Type": "application/octet-stream",
106+
"Content-Range": contentRange,
107+
"Content-Length": chunk.length.toString(),
108+
},
109+
body: chunk,
110+
});
111+
112+
if (!response.ok) {
113+
throw new Error(`Failed to upload chunk: ${response.statusText}`);
114+
}
115+
116+
const responseBody = await response.json();
117+
118+
// Update start and end based on next expected ranges
119+
const nextExpectedRanges = responseBody.nextExpectedRanges;
120+
if (nextExpectedRanges && nextExpectedRanges.length > 0) {
121+
const nextRange = nextExpectedRanges[0].split("-");
122+
start = parseInt(nextRange[0], 10);
123+
end = Math.min(start + UPLOAD_ATTACHMENT_CHUNK_SIZE - 1, fileSize - 1);
124+
} else {
125+
start += UPLOAD_ATTACHMENT_CHUNK_SIZE;
126+
end = Math.min(start + UPLOAD_ATTACHMENT_CHUNK_SIZE - 1, fileSize - 1);
127+
}
128+
}
129+
130+
logger.info(`File ${path} uploaded successfully in chunks.`);
131+
} else {
132+
try {
133+
const response = await this.client
134+
.api(`/me/messages/${messageID}/attachments`)
135+
.post({
136+
"@odata.type": "#microsoft.graph.fileAttachment",
137+
name: filename,
138+
contentBytes: fileData.toString("base64"),
139+
});
140+
logger.debug(`File ${path} uploaded with response: ${JSON.stringify(response)}.`);
141+
} catch (error) {
142+
console.error("Error uploading file: ", error);
143+
}
144+
}
145+
}
146+
147+
public async uploadEmail(
148+
to: string[],
149+
subject: string,
150+
html: string,
151+
attachmentPaths: string[] = [],
152+
additionalInfo: { cc: EmailString[]; bcc: EmailString[] } = { cc: [], bcc: [] },
153+
text: string = convert(html),
154+
) {
155+
if (!this.client) {
156+
throw new Error("Client not authenticated");
157+
}
158+
try {
159+
const draftMessage = {
160+
subject,
161+
body: {
162+
contentType: "HTML",
163+
content: html,
164+
},
165+
toRecipients: to.map((email) => ({
166+
emailAddress: {
167+
address: email,
168+
},
169+
})),
170+
ccRecipients: additionalInfo.cc.map((email) => ({
171+
emailAddress: {
172+
address: email,
173+
},
174+
})),
175+
bccRecipients: additionalInfo.bcc.map((email) => ({
176+
emailAddress: {
177+
address: email,
178+
},
179+
})),
180+
};
181+
182+
const response = await this.client.api("/me/messages").post(draftMessage);
183+
logger.debug("Draft email created with ID: ", response.id);
184+
185+
if (attachmentPaths.length > 0) {
186+
logger.info("Uploading attachments...");
187+
await Promise.all(
188+
attachmentPaths.map((path) => this.uploadFile(path, response.id)),
189+
);
190+
}
191+
} catch (error) {
192+
console.error("Error uploading draft email: ", error);
193+
}
194+
}
195+
}

0 commit comments

Comments
 (0)