Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 56 additions & 37 deletions src/m365/outlook/commands/mail/mail-send.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@ import { pid } from '../../../../utils/pid.js';
import { session } from '../../../../utils/session.js';
import { sinonUtil } from '../../../../utils/sinonUtil.js';
import commands from '../../commands.js';
import command from './mail-send.js';
import command, { options } from './mail-send.js';

describe(commands.MAIL_SEND, () => {
let log: string[];
let logger: Logger;
let commandInfo: CommandInfo;
let commandOptionsSchema: typeof options;

before(() => {
sinon.stub(auth, 'restoreAuth').resolves();
Expand All @@ -32,6 +33,7 @@ describe(commands.MAIL_SEND, () => {
accessToken: 'abc'
};
commandInfo = cli.getCommandInfo(command);
commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options;
});

beforeEach(() => {
Expand Down Expand Up @@ -311,17 +313,25 @@ describe(commands.MAIL_SEND, () => {
new CommandError(`An error has occurred`));
});

it('fails validation if bodyContentType is invalid', async () => {
const actual = await command.validate({ options: { subject: 'Lorem ipsum', to: 'mail@domain.com', bodyContents: 'Lorem ipsum', bodyContentType: 'Invalid' } }, commandInfo);
assert.notStrictEqual(actual, true);
it('defines schema', () => {
assert.notStrictEqual(command.schema, undefined);
});

it('fails validation if importance is invalid', async () => {
const actual = await command.validate({ options: { subject: 'Lorem ipsum', to: 'mail@domain.com', bodyContents: 'Lorem ipsum', importance: 'Invalid' } }, commandInfo);
assert.notStrictEqual(actual, true);
it('defines refined schema', () => {
assert.notStrictEqual(command.getRefinedSchema(command.schema as any), undefined);
});

it('fails validation if file doesn\'t exist', async () => {
it('fails validation if bodyContentType is invalid', () => {
const actual = commandOptionsSchema.safeParse({ subject: 'Lorem ipsum', to: 'mail@domain.com', bodyContents: 'Lorem ipsum', bodyContentType: 'Invalid' });
assert.notStrictEqual(actual.success, true);
});

it('fails validation if importance is invalid', () => {
const actual = commandOptionsSchema.safeParse({ subject: 'Lorem ipsum', to: 'mail@domain.com', bodyContents: 'Lorem ipsum', importance: 'Invalid' });
assert.notStrictEqual(actual.success, true);
});

it('fails validation if file doesn\'t exist', () => {
sinon.stub(fs, 'lstatSync').returns({ isFile: () => true } as any);
sinon.stub(fs, 'existsSync').callsFake(path => {
if (path.toString() === 'C:/File2.txt') {
Expand All @@ -331,11 +341,11 @@ describe(commands.MAIL_SEND, () => {
return true;
});

const actual = await command.validate({ options: { subject: 'Lorem ipsum', to: 'mail@domain.com', bodyContents: 'Lorem ipsum', attachment: ['C:/File.txt', 'C:/File2.txt'] } }, commandInfo);
assert.notStrictEqual(actual, true);
const actual = commandOptionsSchema.safeParse({ subject: 'Lorem ipsum', to: 'mail@domain.com', bodyContents: 'Lorem ipsum', attachment: ['C:/File.txt', 'C:/File2.txt'] });
assert.strictEqual(actual.success, false);
});

it('fails validation if attachment is not a file', async () => {
it('fails validation if attachment is not a file', () => {
sinon.stub(fs, 'existsSync').returns(true);
sinon.stub(fs, 'lstatSync').callsFake(path => {
if (path.toString() === 'C:/File2.txt') {
Expand All @@ -345,11 +355,11 @@ describe(commands.MAIL_SEND, () => {
return { isFile: () => true } as any;
});

const actual = await command.validate({ options: { subject: 'Lorem ipsum', to: 'mail@domain.com', bodyContents: 'Lorem ipsum', attachment: ['C:/File.txt', 'C:/File2.txt'] } }, commandInfo);
assert.notStrictEqual(actual, true);
const actual = commandOptionsSchema.safeParse({ subject: 'Lorem ipsum', to: 'mail@domain.com', bodyContents: 'Lorem ipsum', attachment: ['C:/File.txt', 'C:/File2.txt'] });
assert.strictEqual(actual.success, false);
});

it('fails validation if attachments are too large', async () => {
it('fails validation if attachments are too large', () => {
sinon.stub(fs, 'existsSync').returns(true);
sinon.stub(fs, 'lstatSync').returns({ isFile: () => true } as any);
sinon.stub(fs, 'readFileSync').callsFake(path => {
Expand All @@ -360,43 +370,52 @@ describe(commands.MAIL_SEND, () => {
throw 'Invalid read request';
});

const actual = await command.validate({ options: { subject: 'Lorem ipsum', to: 'mail@domain.com', bodyContents: 'Lorem ipsum', attachment: 'C:/File.txt' } }, commandInfo);
assert.notStrictEqual(actual, true);
const actual = commandOptionsSchema.safeParse({ subject: 'Lorem ipsum', to: 'mail@domain.com', bodyContents: 'Lorem ipsum', attachment: 'C:/File.txt' });
assert.strictEqual(actual.success, false);
});

it('passes validation when valid attachments are specified', () => {
sinon.stub(fs, 'existsSync').returns(true);
sinon.stub(fs, 'lstatSync').returns({ isFile: () => true } as any);
sinon.stub(fs, 'readFileSync').returns('file content');

const actual = commandOptionsSchema.safeParse({ subject: 'Lorem ipsum', to: 'mail@domain.com', bodyContents: 'Lorem ipsum', attachment: 'C:/File.txt' });
assert.strictEqual(actual.success, true);
});

it('passes validation when subject, to and bodyContents are specified', async () => {
const actual = await command.validate({ options: { subject: 'Lorem ipsum', to: 'mail@domain.com', bodyContents: 'Lorem ipsum' } }, commandInfo);
assert.strictEqual(actual, true);
it('passes validation when subject, to and bodyContents are specified', () => {
const actual = commandOptionsSchema.safeParse({ subject: 'Lorem ipsum', to: 'mail@domain.com', bodyContents: 'Lorem ipsum' });
assert.strictEqual(actual.success, true);
});

it('passes validation when multiple to emails are specified', async () => {
const actual = await command.validate({ options: { subject: 'Lorem ipsum', to: 'mail@domain.com,mail2@domain.com', bodyContents: 'Lorem ipsum' } }, commandInfo);
assert.strictEqual(actual, true);
it('passes validation when multiple to emails are specified', () => {
const actual = commandOptionsSchema.safeParse({ subject: 'Lorem ipsum', to: 'mail@domain.com,mail2@domain.com', bodyContents: 'Lorem ipsum' });
assert.strictEqual(actual.success, true);
});

it('passes validation when multiple to emails separated with command and space are specified', async () => {
const actual = await command.validate({ options: { subject: 'Lorem ipsum', to: 'mail@domain.com, mail2@domain.com', bodyContents: 'Lorem ipsum' } }, commandInfo);
assert.strictEqual(actual, true);
it('passes validation when multiple to emails separated with command and space are specified', () => {
const actual = commandOptionsSchema.safeParse({ subject: 'Lorem ipsum', to: 'mail@domain.com, mail2@domain.com', bodyContents: 'Lorem ipsum' });
assert.strictEqual(actual.success, true);
});

it('passes validation when bodyContentType is set to Text', async () => {
const actual = await command.validate({ options: { subject: 'Lorem ipsum', to: 'mail@domain.com', bodyContents: 'Lorem ipsum', bodyContentType: 'Text' } }, commandInfo);
assert.strictEqual(actual, true);
it('passes validation when bodyContentType is set to Text', () => {
const actual = commandOptionsSchema.safeParse({ subject: 'Lorem ipsum', to: 'mail@domain.com', bodyContents: 'Lorem ipsum', bodyContentType: 'Text' });
assert.strictEqual(actual.success, true);
});

it('passes validation when bodyContentType is set to HTML', async () => {
const actual = await command.validate({ options: { subject: 'Lorem ipsum', to: 'mail@domain.com', bodyContents: 'Lorem ipsum', bodyContentType: 'HTML' } }, commandInfo);
assert.strictEqual(actual, true);
it('passes validation when bodyContentType is set to HTML', () => {
const actual = commandOptionsSchema.safeParse({ subject: 'Lorem ipsum', to: 'mail@domain.com', bodyContents: 'Lorem ipsum', bodyContentType: 'HTML' });
assert.strictEqual(actual.success, true);
});

it('passes validation when saveToSentItems is set to false', async () => {
const actual = await command.validate({ options: { subject: 'Lorem ipsum', to: 'mail@domain.com', bodyContents: 'Lorem ipsum', saveToSentItems: false } }, commandInfo);
assert.strictEqual(actual, true);
it('passes validation when saveToSentItems is set to false', () => {
const actual = commandOptionsSchema.safeParse({ subject: 'Lorem ipsum', to: 'mail@domain.com', bodyContents: 'Lorem ipsum', saveToSentItems: false });
assert.strictEqual(actual.success, true);
});

it('passes validation when saveToSentItems is set to true', async () => {
const actual = await command.validate({ options: { subject: 'Lorem ipsum', to: 'mail@domain.com', bodyContents: 'Lorem ipsum', saveToSentItems: true } }, commandInfo);
assert.strictEqual(actual, true);
it('passes validation when saveToSentItems is set to true', () => {
const actual = commandOptionsSchema.safeParse({ subject: 'Lorem ipsum', to: 'mail@domain.com', bodyContents: 'Lorem ipsum', saveToSentItems: true });
assert.strictEqual(actual.success, true);
});

it('sends email using a specified group mailbox', async () => {
Expand Down
145 changes: 46 additions & 99 deletions src/m365/outlook/commands/mail/mail-send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,35 @@ import fs from 'fs';
import path from 'path';
import auth from '../../../../Auth.js';
import { Logger } from '../../../../cli/Logger.js';
import GlobalOptions from '../../../../GlobalOptions.js';
import { globalOptionsZod } from '../../../../Command.js';
import request, { CliRequestOptions } from '../../../../request.js';
import { accessToken } from '../../../../utils/accessToken.js';
import { formatting } from '../../../../utils/formatting.js';
import GraphCommand from '../../../base/GraphCommand.js';
import commands from '../../commands.js';
import { z } from 'zod';

export const options = z.strictObject({
...globalOptionsZod.shape,
subject: z.string().alias('s'),
to: z.string().alias('t'),
cc: z.string().optional(),
bcc: z.string().optional(),
sender: z.string().optional(),
mailbox: z.string().optional().alias('m'),
bodyContents: z.string(),
bodyContentType: z.enum(['Text', 'HTML']).optional(),
importance: z.enum(['low', 'normal', 'high']).optional(),
attachment: z.union([z.string(), z.string().array()]).optional(),
saveToSentItems: z.boolean().optional()
});

declare type Options = z.infer<typeof options>;

interface CommandArgs {
options: Options;
}

interface Options extends GlobalOptions {
subject: string;
to: string;
cc?: string;
bcc?: string;
sender?: string;
mailbox?: string;
bodyContents: string;
bodyContentType?: string;
importance?: string;
attachment?: string | string[];
saveToSentItems?: boolean;
}

class OutlookMailSendCommand extends GraphCommand {
public get name(): string {
return commands.MAIL_SEND;
Expand All @@ -36,90 +40,40 @@ class OutlookMailSendCommand extends GraphCommand {
return 'Sends an email';
}

constructor() {
super();

this.#initTelemetry();
this.#initOptions();
this.#initTypes();
this.#initValidators();
public get schema(): z.ZodType | undefined {
return options;
}

#initTelemetry(): void {
this.telemetry.push((args: CommandArgs) => {
Object.assign(this.telemetryProperties, {
cc: typeof args.options.cc !== 'undefined',
bcc: typeof args.options.bcc !== 'undefined',
bodyContentType: args.options.bodyContentType,
saveToSentItems: args.options.saveToSentItems,
importance: args.options.importance,
mailbox: typeof args.options.mailbox !== 'undefined',
sender: typeof args.options.sender !== 'undefined',
attachment: typeof args.options.attachment !== 'undefined'
});
});
}
public getRefinedSchema(schema: typeof options): z.ZodType | undefined {
return schema
.refine(options => {
if (!options.attachment) {
return true;
}

#initOptions(): void {
this.options.unshift(
{
option: '-s, --subject <subject>'
},
{
option: '-t, --to <to>'
},
{
option: '--cc [cc]'
},
{
option: '--bcc [bcc]'
},
{
option: '--sender [sender]'
},
{
option: '-m, --mailbox [mailbox]'
},
{
option: '--bodyContents <bodyContents>'
},
{
option: '--bodyContentType [bodyContentType]',
autocomplete: ['Text', 'HTML']
},
{
option: '--importance [importance]',
autocomplete: ['low', 'normal', 'high']
},
{
option: '--attachment [attachment]'
},
{
option: '--saveToSentItems [saveToSentItems]',
autocomplete: ['true', 'false']
}
);
}
const attachments: string[] = typeof options.attachment === 'string' ? [options.attachment] : options.attachment;

#initTypes(): void {
this.types.boolean.push('saveToSentItems');
}
for (const attachment of attachments) {
if (!fs.existsSync(attachment)) {
return false;
}

#initValidators(): void {
this.validators.push(
async (args: CommandArgs) => {
if (args.options.bodyContentType &&
args.options.bodyContentType !== 'Text' &&
args.options.bodyContentType !== 'HTML') {
return `${args.options.bodyContentType} is not a valid value for the bodyContentType option. Allowed values are Text|HTML`;
if (!fs.lstatSync(attachment).isFile()) {
return false;
}
}

if (args.options.importance && ['low', 'normal', 'high'].indexOf(args.options.importance) === -1) {
return `'${args.options.importance}' is not a valid value for the importance option. Allowed values are low|normal|high`;
const requestBody = this.getRequestBody(options);
// The max body size of the request is 4 194 304 chars before getting a 413 response
if (JSON.stringify(requestBody).length > 4_194_304) {
return false;
}

if (args.options.attachment) {
const attachments: string[] = typeof args.options.attachment === 'string' ? [args.options.attachment] : args.options.attachment;
return true;
}, {
error: (ctx: { input: unknown }) => {
const opts = ctx.input as Options;
const attachments: string[] = typeof opts.attachment === 'string' ? [opts.attachment!] : opts.attachment!;

for (const attachment of attachments) {
if (!fs.existsSync(attachment)) {
Expand All @@ -131,16 +85,9 @@ class OutlookMailSendCommand extends GraphCommand {
}
}

const requestBody = this.getRequestBody(args.options);
// The max body size of the request is 4 194 304 chars before getting a 413 response
if (JSON.stringify(requestBody).length > 4_194_304) {
return 'Exceeded the max total size of attachments which is 3MB.';
}
return 'Exceeded the max total size of attachments which is 3MB.';
}

return true;
}
);
});
}

public async commandAction(logger: Logger, args: CommandArgs): Promise<void> {
Expand Down
Loading
Loading