Skip to content

Commit

Permalink
Merge pull request #8 from icdocsoc/feat-imap-drafts
Browse files Browse the repository at this point in the history
Feat: Add uploading of draft emails
  • Loading branch information
Gum-Joe authored Aug 4, 2024
2 parents 573fe7f + c319d40 commit 6565849
Show file tree
Hide file tree
Showing 50 changed files with 1,086 additions and 156 deletions.
2 changes: 1 addition & 1 deletion common/util/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"build": "tsc --build --verbose",
"build-local": "tsc --build --verbose"
},
"type": "commonjs",
"type": "module",
"main": "./dist/index.js",
"typings": "./dist/index.d.ts"
}
4 changes: 2 additions & 2 deletions common/util/src/files.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { stopIfCriticalFsError } from "./files";
import createLogger from "./logger";
import { stopIfCriticalFsError } from "./files.js";
import createLogger from "./logger.js";

jest.mock("./logger", () => {
const logger = {
Expand Down
2 changes: 1 addition & 1 deletion common/util/src/files.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import createLogger from "./logger";
import createLogger from "./logger.js";

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

Expand Down
4 changes: 2 additions & 2 deletions common/util/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export * from "./files";
export { default as createLogger } from "./logger";
export * from "./files.js";
export { default as createLogger } from "./logger.js";
2 changes: 1 addition & 1 deletion common/util/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"module": "esnext",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
Expand Down
33 changes: 25 additions & 8 deletions email/libmailmerge/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,25 +1,42 @@
{
"extends": ["../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"extends": [
"../../.eslintrc.json"
],
"ignorePatterns": [
"!**/*"
],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"files": [
"*.ts",
"*.tsx",
"*.js",
"*.jsx"
],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"files": [
"*.ts",
"*.tsx"
],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"files": [
"*.js",
"*.jsx"
],
"rules": {}
},
{
"files": ["*.json"],
"files": [
"*.json"
],
"parser": "jsonc-eslint-parser",
"rules": {
"@nx/dependency-checks": "error"
"@nx/dependency-checks": "warn"
}
}
]
}
}
17 changes: 13 additions & 4 deletions email/libmailmerge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@
"name": "@docsoc/libmailmerge",
"version": "0.1.0",
"dependencies": {
"@azure/identity": "^4.4.1",
"@docsoc/util": "^0.1.0",
"@microsoft/microsoft-graph-client": "^3.0.7",
"chalk": "^5.3.0",
"cli-progress": "^3.12.0",
"email-validator": "^2.0.4",
"html-to-text": "^9.0.5",
"markdown-it": "^14.1.0",
"nodemailer": "^6.9.14",
"nunjucks": "^3.2.4",
"tslib": "^2.3.0",
"@docsoc/util": "^0.1.0"
"readline-sync": "^1.4.10",
"tslib": "^2.3.0"
},
"scripts": {
"start": "ts-node src/cli.ts",
Expand All @@ -17,17 +22,21 @@
"build-local": "tsc --build --verbose"
},
"bin": "./dist/cli.js",
"type": "commonjs",
"type": "module",
"main": "./dist/index.js",
"typings": "./dist/index.d.ts",
"private": true,
"devDependencies": {
"@types/chalk": "^2.2.0",
"@types/cli-progress": "^3.11.6",
"@types/csv-parse": "^1.2.2",
"@types/dotenv": "^8.2.0",
"@types/email-validator": "^1.0.6",
"@types/express": "^4.17.21",
"@types/html-to-text": "^9.0.4",
"@types/imapflow": "^1.0.19",
"@types/markdown-it": "^14.1.1",
"@types/mime-types": "^2.1.4",
"@types/nodemailer": "^6.4.15",
"@types/nunjucks": "^3.2.6",
"@types/readline-sync": "^1.4.8",
Expand All @@ -36,4 +45,4 @@
"jest-mock-extended": "^3.0.7",
"ts-node": "^10.9.2"
}
}
}
10 changes: 5 additions & 5 deletions email/libmailmerge/src/engines/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* Contains the different templating engines mail merge supports
* @module engines
*/
import nunjucksEngine from "./nunjucks-md";
import { TemplateEngineConstructor } from "./types";
import nunjucksEngine from "./nunjucks-md/index.js";
import { TemplateEngineConstructor } from "./types.js";

export type TEMPLATE_ENGINES = "nunjucks";

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

export * from "./types";
export * from "./nunjucks-md";
export { default as NunjucksMarkdownEngine } from "./nunjucks-md";
export * from "./types.js";
export * from "./nunjucks-md/index.js";
export { default as NunjucksMarkdownEngine } from "./nunjucks-md/index.js";
16 changes: 8 additions & 8 deletions email/libmailmerge/src/engines/nunjucks-md/index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { promises as fs } from "fs";
import nunjucks from "nunjucks";

import { renderMarkdownToHtml } from "../../markdown/toHtml";
import { MappedCSVRecord } from "../../util/types";
import { TemplateEngineOptions, TemplatePreviews } from "../types";
import { TemplateEngine } from "../types";
import getTemplateFields from "./getFields";
import { renderMarkdownToHtml } from "../../markdown/toHtml.js";
import { MappedCSVRecord } from "../../util/types.js";
import { TemplateEngineOptions, TemplatePreviews } from "../types.js";
import { TemplateEngine } from "../types.js";
import getTemplateFields from "./getFields.js";
import {
assertIsNunjucksTemplateOptions,
NunjucksMarkdownTemplateOptions,
NunjucksSidecarMetadata,
} from "./types";
} from "./types.js";

export { default as getNunjucksTemplateFields } from "./getFields";
export * from "./types";
export { default as getNunjucksTemplateFields } from "./getFields.js";
export * from "./types.js";

/**
* A Nunjucks Markdown template engine
Expand Down
2 changes: 1 addition & 1 deletion email/libmailmerge/src/engines/nunjucks-md/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TemplateEngineOptions } from "../types";
import { TemplateEngineOptions } from "../types.js";

/**
* Nunjucks template options
Expand Down
1 change: 1 addition & 0 deletions email/libmailmerge/src/graph/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./uploadDrafts.js";
195 changes: 195 additions & 0 deletions email/libmailmerge/src/graph/uploadDrafts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { InteractiveBrowserCredential } from "@azure/identity";
import { createLogger } from "@docsoc/util";
import { Client } from "@microsoft/microsoft-graph-client";
import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials/index.js";
import fs from "fs/promises";
import { convert } from "html-to-text";
import { basename } from "path";

import { EmailString } from "../util/types.js";

const logger = createLogger("imap");

export interface ImapConfig {
host: string;
port: number;
username: string;
}

/** Docs say limit to 4MB uplod chuncks for large files */
const UPLOAD_ATTACHMENT_CHUNK_SIZE = 4 * 1024 * 1024;

export class EmailUploader {
private client?: Client;

public async authenticate(desiredEmail: string, tenantId?: string, clientId?: string) {
logger.info("Getting OAuth token using Microsoft libraries...");

if (!tenantId) {
throw new Error("Tenant ID not provided");
}
if (!clientId) {
throw new Error("Client ID not provided");
}

const credential = new InteractiveBrowserCredential({
tenantId: tenantId,
clientId: clientId,
redirectUri: "http://localhost",
});

// @microsoft/microsoft-graph-client/authProviders/azureTokenCredentials
const authProvider = new TokenCredentialAuthenticationProvider(credential, {
scopes: ["Mail.ReadWrite"],
});

this.client = Client.initWithMiddleware({ authProvider: authProvider });

try {
const user = await this.client.api("/me").get();
if (user.mail === desiredEmail || user.userPrincipalName === desiredEmail) {
logger.info(`Authenticated user email matches the provided email ${desiredEmail}.`);
} else {
logger.error(
`Authenticated user email does not match the provided email ${desiredEmail}.`,
);
throw new Error(
`Authenticated user email does not match the provided email ${desiredEmail}.`,
);
}
} catch (error) {
logger.error("Error fetching user profile:", error);
throw error;
}
}

private async uploadFile(path: string, messageID: string) {
logger.info(`Uploading file ${path}...`);
if (!this.client) {
throw new Error("Client not authenticated");
}
const fileData = await fs.readFile(path);
const fileStats = await fs.stat(path);
const filename = basename(path);

// If above 3MB
if (fileStats.size > 3 * 1024 * 1024) {
// 1: Create upload session
const uploadSession = await this.client
.api(`/me/messages/${messageID}/attachments/createUploadSession`)
.post({
AttachmentItem: {
attachmentType: "file",
name: filename,
size: fileStats.size,
},
});
const { uploadUrl, nextExpectedRanges } = uploadSession;

if (!uploadUrl) {
throw new Error("No upload URL returned from createUploadSession.");
}

// 2: Upload file in chunks
let start = 0;
let end = UPLOAD_ATTACHMENT_CHUNK_SIZE - 1;
const fileSize = fileStats.size;

while (start < fileSize) {
const chunk = fileData.slice(start, end + 1);
const contentRange = `bytes ${start}-${end}/${fileSize}`;

const response = await fetch(uploadUrl, {
method: "PUT",
headers: {
"Content-Type": "application/octet-stream",
"Content-Range": contentRange,
"Content-Length": chunk.length.toString(),
},
body: chunk,
});

if (!response.ok) {
throw new Error(`Failed to upload chunk: ${response.statusText}`);
}

const responseBody = await response.json();

// Update start and end based on next expected ranges
const nextExpectedRanges = responseBody.nextExpectedRanges;
if (nextExpectedRanges && nextExpectedRanges.length > 0) {
const nextRange = nextExpectedRanges[0].split("-");
start = parseInt(nextRange[0], 10);
end = Math.min(start + UPLOAD_ATTACHMENT_CHUNK_SIZE - 1, fileSize - 1);
} else {
start += UPLOAD_ATTACHMENT_CHUNK_SIZE;
end = Math.min(start + UPLOAD_ATTACHMENT_CHUNK_SIZE - 1, fileSize - 1);
}
}

logger.info(`File ${path} uploaded successfully in chunks.`);
} else {
try {
const response = await this.client
.api(`/me/messages/${messageID}/attachments`)
.post({
"@odata.type": "#microsoft.graph.fileAttachment",
name: filename,
contentBytes: fileData.toString("base64"),
});
logger.debug(`File ${path} uploaded with response: ${JSON.stringify(response)}.`);
} catch (error) {
console.error("Error uploading file: ", error);
}
}
}

public async uploadEmail(
to: string[],
subject: string,
html: string,
attachmentPaths: string[] = [],
additionalInfo: { cc: EmailString[]; bcc: EmailString[] } = { cc: [], bcc: [] },
text: string = convert(html),
) {
if (!this.client) {
throw new Error("Client not authenticated");
}
try {
const draftMessage = {
subject,
body: {
contentType: "HTML",
content: html,
},
toRecipients: to.map((email) => ({
emailAddress: {
address: email,
},
})),
ccRecipients: additionalInfo.cc.map((email) => ({
emailAddress: {
address: email,
},
})),
bccRecipients: additionalInfo.bcc.map((email) => ({
emailAddress: {
address: email,
},
})),
};

const response = await this.client.api("/me/messages").post(draftMessage);
logger.debug("Draft email created with ID: ", response.id);

if (attachmentPaths.length > 0) {
logger.info("Uploading attachments...");
await Promise.all(
attachmentPaths.map((path) => this.uploadFile(path, response.id)),
);
}
} catch (error) {
console.error("Error uploading draft email: ", error);
}
}
}
Loading

0 comments on commit 6565849

Please sign in to comment.