Skip to content

Commit 9cfd3fd

Browse files
committed
feat: add inline images to draft uploads
1 parent 5691f94 commit 9cfd3fd

8 files changed

Lines changed: 101 additions & 54 deletions

File tree

email/mailmerge-cli/src/commands/upload-drafts.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ export default class UploadDrafts extends Command {
3434
char: "n",
3535
description: "Only send this many emails (i.e. the first X emails)",
3636
}),
37+
inlineImages: Flags.string({
38+
char: "i",
39+
description:
40+
"Path to a JSON file containing informationa about inline images - see InlineImagesSpec type for format",
41+
}),
3742
};
3843

3944
public async run(): Promise<void> {
@@ -49,6 +54,7 @@ export default class UploadDrafts extends Command {
4954
await uploadDrafts(storageBackend, ENGINES_MAP, flags.yes, {
5055
sleepBetween: flags.sleepBetween,
5156
onlySend: flags.only,
57+
inlineImages: flags.inlineImages,
5258
});
5359
}
5460
}

email/mailmerge/src/engines/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
* For example, the {@link NunjucksMarkdownEngine} will return a markdown preview and an HTML preview,
1414
* and will allow the user to edit the markdown preview and then rerender the HTML preview.
1515
*
16-
* ### Example simpl usage:
16+
* ### Example simple usage:
1717
* ```typescript
1818
* import { NunjucksMarkdownEngine } from '@docsoc/mailmerge';
1919
*

email/mailmerge/src/engines/react/index.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import React from "react";
55
import { MappedRecord } from "../../util/types.js";
66
import { TemplateEngine, TemplateEngineOptions, TemplatePreviews } from "../types.js";
77

8+
/** Valid types we can pass through as props to rendered templates */
89
export enum PropTypes {
910
String = "string",
1011
Number = "number",
1112
Boolean = "boolean",
13+
/** Supported when using JSON as a data source */
1214
Array = "array",
13-
/** An arbitrary object */
15+
/** An arbitrary object (supported when using JSON as a data source) */
1416
Object = "object",
1517
}
1618

@@ -31,7 +33,7 @@ export interface ReactEmailEngineExports<
3133
*/
3234
parameters: () => Record<string, PropTypes>;
3335
/**
34-
* Return the template engine itself - essentially a function that takes a T and return a React component./
36+
* Return the template engine itself as the default export - essentially a function that takes a T and return a React component./
3537
*/
3638
default: React.FC<T>;
3739
}
@@ -45,12 +47,16 @@ export interface ReactEmailEngineOptions {
4547
[key: string]: string;
4648
}
4749

50+
/** Sidecar metadata to store in sidecar files mailmerge generates so we can reload templates later */
4851
export interface ReactEmailSidecarMetadata {
4952
type: "react";
53+
/** Store the path to the original JSX file we loaded, so we can rerun the render method */
5054
templatePath: string;
55+
// Needed or TS complains
5156
[key: string]: unknown;
5257
}
5358

59+
// TS hack to get type inference on the arbitrary option object mailmerge gives us
5460
function assertIsReactEmailTemplateOptions(
5561
options: Record<string, unknown>,
5662
): asserts options is ReactEmailEngineOptions {
@@ -64,7 +70,9 @@ function assertIsReactEmailTemplateOptions(
6470
*
6571
* The email engine here returns one preview, which contains the HTML to send.
6672
*
67-
* On rerender, it will re-render the React component and return the new HTML.
73+
* On rerender, it will re-render the React component (by reloading the original template that is stores) and return the new HTML.
74+
* Through this, unlike nunjucks, we can update our template and run re-render to update the outputs
75+
* (in nunjucks you can only edit the generated markdown files and rerender to generate the output HTML)
6876
*/
6977
export default class ReactEmailEngine extends TemplateEngine {
7078
private loadedTemplate?: ReactEmailEngineExports;
@@ -119,13 +127,13 @@ export default class ReactEmailEngine extends TemplateEngine {
119127
];
120128
}
121129
/**
122-
* Re-rendering in react really m
130+
* Re-rendering by reloading the template and regenerateing HTML
123131
*/
124132
public override async rerenderPreviews(
125133
loadedPreviews: TemplatePreviews,
126134
associatedRecord: MappedRecord,
127135
): Promise<TemplatePreviews> {
128-
// 1: do we have at least one preview?
136+
// 1: do we have at least one preview? (we only generate one preview, see renderPreview)
129137
if (loadedPreviews.length === 0) {
130138
throw new Error("No previews to re-render");
131139
}
@@ -156,7 +164,7 @@ export default class ReactEmailEngine extends TemplateEngine {
156164
];
157165
}
158166

159-
/** Return the first preview */
167+
/** Return the first preview as we only generate one that being the HTML to send */
160168
public override async getHTMLToSend(loadedPreviews: TemplatePreviews): Promise<string> {
161169
return loadedPreviews[0].content;
162170
}

email/mailmerge/src/graph/uploadDrafts.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,9 @@ export class EmailUploader {
8484
* Upload attachments to a created message
8585
* @param path Path to attachment to upload
8686
* @param messageID ID of the message to upload the attachment to
87+
* @param cid Optional ContentID to upload under for use in IMG tags.
8788
*/
88-
private async uploadFile(path: string, messageID: string) {
89+
private async uploadFile(path: string, messageID: string, cid?: string) {
8990
this.logger.info(`Uploading file ${path}...`);
9091
if (!this.client) {
9192
throw new Error("Client not authenticated");
@@ -108,6 +109,7 @@ export class EmailUploader {
108109
attachmentType: "file",
109110
name: filename,
110111
size: fileStats.size,
112+
contentId: cid,
111113
},
112114
});
113115
const { uploadUrl } = uploadSession;
@@ -187,15 +189,15 @@ export class EmailUploader {
187189
* @param to List of email addresses to send to
188190
* @param subject Subject of the email
189191
* @param html HTML content of the email
190-
* @param attachmentPaths List of paths to attachments to upload
192+
* @param attachmentPaths List of paths to attachments to upload, with their CIDs if needed
191193
* @param additionalInfo Additional info for the email (cc, bcc)
192194
* @param options Options for the uploader (e.g. enabling hacks to get around Outlook limitations)
193195
*/
194196
public async uploadEmail(
195197
to: string[],
196198
subject: string,
197199
html: string,
198-
attachmentPaths: string[] = [],
200+
attachmentPaths: (string | { path: string; cid: string })[] = [],
199201
additionalInfo: { cc: EmailString[]; bcc: EmailString[] } = { cc: [], bcc: [] },
200202
options: {
201203
/**
@@ -246,7 +248,11 @@ export class EmailUploader {
246248
if (attachmentPaths.length > 0) {
247249
this.logger.info("Uploading attachments...");
248250
for (const path of attachmentPaths) {
249-
await this.uploadFile(path, response.id);
251+
if (typeof path === "string") {
252+
await this.uploadFile(path, response.id);
253+
} else {
254+
await this.uploadFile(path.path, response.id, path.cid);
255+
}
250256
}
251257
}
252258
} catch (error) {

email/mailmerge/src/pipelines/send.ts

Lines changed: 4 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,23 @@ import { createLogger } from "@docsoc/util";
22
import chalk from "chalk";
33
// Load dotenv
44
import "dotenv/config";
5-
import { promises as fs } from "fs";
65
import readlineSync from "readline-sync";
76

87
import { ENGINES_MAP } from "../engines/index.js";
98
import { TemplateEngineConstructor } from "../engines/types.js";
109
import Mailer from "../mailer/mailer.js";
10+
import { InlineImagesSpec, loadInlineImageJSON } from "../util/inline-images.js";
1111
import { EmailString, FromEmail } from "../util/types.js";
1212
import { StorageBackend, MergeResultWithMetadata, PostSendActionMode } from "./storage/types.js";
1313

14-
/** See https://www.nodemailer.com/message/embedded-images/ */
15-
export interface InlineImage {
16-
/** Filename to attach to email as */
17-
filename: string;
18-
/** Path to file relative to CWD */
19-
path: string;
20-
/** Content ID to use in the email */
21-
cid: string;
22-
}
23-
24-
export type InlineImagesSpec = InlineImage[];
25-
2614
interface SendEmailsOptions {
2715
/** Time to sleep between sending emails to prevent hitting rate limits */
2816
sleepBetween?: number;
2917
/** Only send this many emails (i.e. the first X emails) */
3018
onlySend?: number;
3119
/** Send the top {@link onlySend} emails to this email as a test */
3220
testSendTo?: EmailString;
33-
/** Path to JSON file conforming to {@link InlineImagesSpec} with attachments */
21+
/** Path to JSON file conforming to a {@link InlineImagesSpec} with files to attach for use as inline images. */
3422
inlineImages?: string;
3523
}
3624

@@ -46,7 +34,7 @@ const DEFAULT_SLEEP_BETWEEN = 0;
4634
* @param enginesMap Map of engine names to engine constructors, as we need to ask the engine what the HTML is to send from the result
4735
* @param disablePrompt If true, will not prompt the user before sending emails. Defaults to false (will prompt)
4836
* @param logger Logger to use for logging
49-
* @param options
37+
* @param options Other options
5038
*/
5139
export async function sendEmails(
5240
storageBackend: StorageBackend,
@@ -70,31 +58,7 @@ export async function sendEmails(
7058
let inlineImages: InlineImagesSpec = [];
7159
if (options.inlineImages) {
7260
logger.info("Loading inline images...");
73-
inlineImages = JSON.parse(await fs.readFile(options.inlineImages, "utf-8"));
74-
// Validate the inline images
75-
if (!Array.isArray(inlineImages)) {
76-
logger.error(
77-
"Invalid inline images - must be an array of InlineImage objects. See https://www.nodemailer.com/message/embedded-images/ for format.",
78-
);
79-
throw new Error("Invalid inline images - must be an array of InlineImage objects.");
80-
}
81-
logger.info(`Loaded ${inlineImages.length} inline images.`);
82-
// Check all the paths are valid (accessble) & have a cid & filename proprty
83-
for (const image of inlineImages) {
84-
if (
85-
typeof image.filename !== "string" ||
86-
typeof image.path !== "string" ||
87-
typeof image.cid !== "string"
88-
) {
89-
logger.error(
90-
"Invalid inline image - must have a filename, path, and cid property. Got ",
91-
image,
92-
);
93-
throw new Error(
94-
`Invalid inline image - must have a filename, path, and cid property. Got ${image}`,
95-
);
96-
}
97-
}
61+
inlineImages = await loadInlineImageJSON(options.inlineImages, logger);
9862
}
9963

10064
// 1: Load data

email/mailmerge/src/pipelines/uploadDrafts.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@ import readlineSync from "readline-sync";
77
import { TemplateEngineConstructor, ENGINES_MAP } from "../engines/index.js";
88
import { EmailUploader } from "../graph/index.js";
99
import { EmailString } from "../util/index.js";
10+
import { InlineImagesSpec, loadInlineImageJSON } from "../util/inline-images.js";
1011
import { StorageBackend, MergeResultWithMetadata, PostSendActionMode } from "./storage/index.js";
1112

1213
interface UploadDraftsOptions {
1314
/** Time to sleep between sending emails to prevent hitting rate limits */
1415
sleepBetween?: number;
15-
/** Only send this many emails (i.e. the first X emails) */
16+
/** Only upload this many emails (i.e. the first X emails) */
1617
onlySend?: number;
18+
/** Path to JSON file conforming to a {@link InlineImagesSpec} with files to attach for use as inline images. */
19+
inlineImages?: string;
1720
}
1821

1922
/**
@@ -55,6 +58,12 @@ export async function uploadDrafts(
5558
logger.info("Loading merge results...");
5659
const results = storageBackend.loadMergeResults();
5760

61+
let inlineImages: InlineImagesSpec = [];
62+
if (options.inlineImages) {
63+
logger.info("Loading inline images...");
64+
inlineImages = await loadInlineImageJSON(options.inlineImages, logger);
65+
}
66+
5867
// For each sidecar, send the previews
5968
const pendingEmails: {
6069
to: EmailString[];
@@ -143,7 +152,7 @@ export async function uploadDrafts(
143152
to,
144153
subject,
145154
html,
146-
attachments,
155+
[...attachments, ...inlineImages],
147156
{
148157
cc,
149158
bcc,
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/** See https://www.nodemailer.com/message/embedded-images/ */
2+
3+
/** How to specify a image we should attach as inline for sending */
4+
import { createLogger } from "@docsoc/util";
5+
import fs from "fs/promises";
6+
7+
export interface InlineImage {
8+
/** Filename to attach to email as */
9+
filename: string;
10+
/** Path to file relative to CWD */
11+
path: string;
12+
/** Content ID to use in the email - use <img src="cid:id123.png" /> if cid is id123.png, say */
13+
cid: string;
14+
}
15+
16+
export type InlineImagesSpec = InlineImage[];
17+
18+
/**
19+
* Load & Validate a JSON containing {@link InlineImagesSpec} for use for inline imags.
20+
* @param path Path to inline images JSON
21+
* @param logger Loggr to use
22+
*/
23+
export async function loadInlineImageJSON(
24+
path: string,
25+
logger = createLogger("docsoc"),
26+
): Promise<InlineImagesSpec> {
27+
logger.info("Loading inline images...");
28+
const inlineImages = JSON.parse(await fs.readFile(path, "utf-8"));
29+
// Validate the inline images
30+
if (!Array.isArray(inlineImages)) {
31+
logger.error(
32+
"Invalid inline images - must be an array of InlineImage objects. See https://www.nodemailer.com/message/embedded-images/ for format.",
33+
);
34+
throw new Error("Invalid inline images - must be an array of InlineImage objects.");
35+
}
36+
logger.info(`Loaded ${inlineImages.length} inline images.`);
37+
// Check all the paths are valid (accessble) & have a cid & filename proprty
38+
for (const image of inlineImages) {
39+
if (
40+
typeof image.filename !== "string" ||
41+
typeof image.path !== "string" ||
42+
typeof image.cid !== "string"
43+
) {
44+
logger.error(
45+
"Invalid inline image - must have a filename, path, and cid property. Got ",
46+
image,
47+
);
48+
throw new Error(
49+
`Invalid inline image - must have a filename, path, and cid property. Got ${image}`,
50+
);
51+
}
52+
}
53+
54+
return inlineImages;
55+
}

email/mailmerge/src/util/types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,3 @@ export type FromEmail = `"${string}" <${EmailString}>`;
33

44
export type RawRecord = Record<string, unknown>;
55
export type MappedRecord = Record<string, unknown>;
6-

0 commit comments

Comments
 (0)