Skip to content

Commit

Permalink
feat: support Microsoft Graph API for emails (#1628)
Browse files Browse the repository at this point in the history
Support Microsoft Graph API for emails

Co-authored-by: Max Novelli <[email protected]>
  • Loading branch information
sbliven and nitrosx authored Jan 28, 2025
1 parent 0667d74 commit 1a82a2c
Show file tree
Hide file tree
Showing 5 changed files with 242 additions and 20 deletions.
8 changes: 6 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,14 @@ REGISTER_METADATA_URI="https://mds.test.datacite.org/metadata"
DOI_USERNAME="username"
DOI_PASSWORD="password"
SITE=<SITE>
EMAIL_TYPE=<"smtp"|"ms365">
EMAIL_FROM=<MESSAGE_FROM>
SMTP_HOST=<SMTP_HOST>
SMTP_MESSAGE_FROM=<SMTP_MESSAGE_FROM>
SMTP_PORT=<SMTP_PORT>
SMTP_SECURE=<SMTP_SECURE>
SMTP_SECURE=<"yes"|"no">
MS365_TENANT_ID=<tenantId>
MS365_CLIENT_ID=<clientId>
MS365_CLIENT_SECRET=<clientSecret>

DATASET_CREATION_VALIDATION_ENABLED=true
DATASET_CREATION_VALIDATION_REGEX="^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$"
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,15 @@ Valid environment variables for the .env file. See [.env.example](/.env.example)
| `REGISTER_DOI_URI` | string | | URI to the organization that registers the facility's DOIs. | |
| `REGISTER_METADATA_URI` | string | | URI to the organization that registers the facility's published data metadata. | |
| `SITE` | string | | The name of your site. | |
| `EMAIL_TYPE` | string | Yes | The type of your email provider. Options are "smtp" or "ms365". | "smtp" |
| `EMAIL_FROM` | string | Yes | Email address that emails should be sent from. | |
| `SMTP_HOST` | string | Yes | Host of SMTP server. | |
| `SMTP_MESSAGE_FROM` | string | Yes | Email address that emails should be sent from. | |
| `SMTP_PORT` | string | Yes | Port of SMTP server. | |
| `SMTP_SECURE` | string | Yes | Secure of SMTP server. | |
| `SMTP_MESSAGE_FROM` | string | Yes | (Deprecated) Alternate spelling of EMAIL_FROM.| |
| `SMTP_PORT` | number | Yes | Port of SMTP server. | 587 |
| `SMTP_SECURE` | bool | Yes | Use encrypted SMTPS. | "no" |
| `MS365_TENANT_ID` | string | Yes | Tenant ID for sending emails over Microsoft Graph API. | |
| `MS365_CLIENT_ID` | string | Yes | Client ID for sending emails over Microsoft Graph API | |
| `MS365_CLIENT_SECRET` | string | Yes | Client Secret for sending emails over Microsoft Graph API | |
| `POLICY_PUBLICATION_SHIFT` | integer | Yes | Embargo period expressed in years. | 3 years |
| `POLICY_RETENTION_SHIFT` | integer | Yes | Retention period (how long the facility will hold on to data) expressed in years. | -1 (indefinitely) |
| `ELASTICSEARCH_ENABLED` | string | | Flag to enable/disable the Elasticsearch endpoints. Values "yes" or "no". | "no" |
Expand Down
57 changes: 47 additions & 10 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ import { EventEmitterModule } from "@nestjs/event-emitter";
import { AdminModule } from "./admin/admin.module";
import { HealthModule } from "./health/health.module";
import { LoggerModule } from "./loggers/logger.module";
import { HttpModule, HttpService } from "@nestjs/axios";
import { MSGraphMailTransport } from "./common/graph-mail";
import { TransportType } from "@nestjs-modules/mailer/dist/interfaces/mailer-options.interface";
import { MetricsModule } from "./metrics/metrics.module";

@Module({
Expand Down Expand Up @@ -61,17 +64,51 @@ import { MetricsModule } from "./metrics/metrics.module";
LogbooksModule,
EventEmitterModule.forRoot(),
MailerModule.forRootAsync({
useFactory: async (configService: ConfigService) => {
const port = configService.get<string>("smtp.port");
imports: [ConfigModule, HttpModule],
useFactory: async (
configService: ConfigService,
httpService: HttpService,
) => {
let transport: TransportType;
const transportType = configService
.get<string>("email.type")
?.toLowerCase();
if (transportType === "smtp") {
transport = {
host: configService.get<string>("email.smtp.host"),
port: configService.get<number>("email.smtp.port"),
secure: configService.get<boolean>("email.smtp.secure"),
};
} else if (transportType === "ms365") {
const tenantId = configService.get<string>("email.ms365.tenantId"),
clientId = configService.get<string>("email.ms365.clientId"),
clientSecret = configService.get<string>(
"email.ms365.clientSecret",
);
if (tenantId === undefined) {
throw new Error("Missing MS365_TENANT_ID");
}
if (clientId === undefined) {
throw new Error("Missing MS365_CLIENT_ID");
}
if (clientSecret === undefined) {
throw new Error("Missing MS365_CLIENT_SECRET");
}
transport = new MSGraphMailTransport(httpService, {
tenantId,
clientId,
clientSecret,
});
} else {
throw new Error(
`Invalid EMAIL_TYPE: ${transportType}. Expect on of "smtp" or "ms365"`,
);
}

return {
transport: {
host: configService.get<string>("smtp.host"),
port: port ? parseInt(port) : undefined,
secure:
configService.get<string>("smtp.secure") === "yes" ? true : false,
},
transport: transport,
defaults: {
from: configService.get<string>("smtp.messageFrom"),
from: configService.get<string>("email.from"),
},
template: {
dir: join(__dirname, "./common/email-templates"),
Expand All @@ -86,7 +123,7 @@ import { MetricsModule } from "./metrics/metrics.module";
},
};
},
inject: [ConfigService],
inject: [ConfigService, HttpService],
}),
MongooseModule.forRootAsync({
useFactory: async (configService: ConfigService) => ({
Expand Down
168 changes: 168 additions & 0 deletions src/common/graph-mail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/**
* This defines a nodemailer transport implementing the MS365 Graph API.
*
* https://learn.microsoft.com/en-us/graph/api/resources/mail-api-overview
*/
import { SentMessageInfo, Transport } from "nodemailer";
import MailMessage from "nodemailer/lib/mailer/mail-message";
import { HttpService } from "@nestjs/axios";
import { Address } from "nodemailer/lib/mailer";
import { firstValueFrom } from "rxjs";
import { Injectable, Logger } from "@nestjs/common";

// Define interface for access token response
interface TokenResponse {
access_token: string;
expires_in: number;
}

interface MSGraphMailTransportOptions {
clientId: string;
clientSecret: string;
refreshToken?: string;
tenantId: string;
}

function getAddress(address: string | Address): {
name?: string;
address: string;
} {
return typeof address === "object" ? address : { address };
}

// Define the Microsoft Graph Transport class
@Injectable()
export class MSGraphMailTransport implements Transport {
name: string;
version: string;
private clientId: string;
private clientSecret: string;
private refreshToken?: string;
private tenantId: string;
private cachedAccessToken: string | null = null;
private tokenExpiry: number | null = null;

constructor(
private readonly httpService: HttpService,
options: MSGraphMailTransportOptions,
) {
this.httpService.axiosRef.defaults.headers["Content-Type"] =
"application/json";
this.name = "Microsoft Graph API Transport";
this.version = "1.0.0";
this.clientId = options.clientId;
this.clientSecret = options.clientSecret;
this.refreshToken = options.refreshToken;
this.tenantId = options.tenantId;
}

// Method to send email using Microsoft Graph API
send(
mail: MailMessage,
callback: (err: Error | null, info?: SentMessageInfo) => void,
): void {
this.getAccessToken().then(
(accessToken) => {
this.sendEmail(accessToken, mail).then(
(info) => {
callback(null, info);
},
(err) => {
callback(err, undefined);
},
);
},
(err) => {
callback(err, undefined);
},
);
}

// Method to fetch or return cached access token
private getAccessToken(): Promise<string> {
if (
this.cachedAccessToken != null &&
Date.now() < (this.tokenExpiry ?? 0)
) {
return ((token: string) =>
new Promise<string>((resolve) => resolve(token)))(
this.cachedAccessToken,
);
}

const body: Record<string, string> = {
client_id: this.clientId,
client_secret: this.clientSecret,
};
if (this.refreshToken) {
body["refresh_token"] = this.refreshToken;
body["grant_type"] = "refresh_token";
} else {
body["grant_type"] = "client_credentials";
body["scope"] = "https://graph.microsoft.com/.default";
}

return firstValueFrom(
this.httpService.post<TokenResponse>(
`https://login.microsoftonline.com/${this.tenantId}/oauth2/v2.0/token`,
body,
{ headers: { "Content-Type": "application/x-www-form-urlencoded" } },
),
).then((response) => {
this.cachedAccessToken = response.data.access_token;
this.tokenExpiry = Date.now() + response.data.expires_in * 1000;

return this.cachedAccessToken;
});
}

private sendEmail(
accessToken: string,
mail: MailMessage,
): Promise<SentMessageInfo> {
const { to, subject, text, html, from } = mail.data;

// Construct email payload for Microsoft Graph API
const emailPayload = {
message: {
subject: subject,
body: {
contentType: html ? "HTML" : "Text",
content: html || text,
},
toRecipients: Array.isArray(to)
? to.map((recipient: string | Address) => getAddress(recipient))
: [{ emailAddress: { address: to } }],
},
};

// Send the email using Microsoft Graph API
return firstValueFrom(
this.httpService.post<void>(
`https://graph.microsoft.com/v1.0/users/${from}/sendMail`,
emailPayload,
{
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
},
),
).then(
(response) => {
if (response.status === 202) {
return {
envelope: mail.message.getEnvelope(),
messageId: mail.message.messageId,
};
}

throw new Error("Failed to send email: " + response.statusText);
},
(err) => {
Logger.error(err);
throw err;
},
);
}
}
18 changes: 13 additions & 5 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,11 +216,19 @@ const configuration = () => {
doiUsername: process.env.DOI_USERNAME,
doiPassword: process.env.DOI_PASSWORD,
site: process.env.SITE,
smtp: {
host: process.env.SMTP_HOST,
messageFrom: process.env.SMTP_MESSAGE_FROM,
port: process.env.SMTP_PORT,
secure: process.env.SMTP_SECURE,
email: {
type: process.env.EMAIL_TYPE || "smtp",
from: process.env.EMAIL_FROM || process.env.SMTP_MESSAGE_FROM,
smtp: {
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || "587"),
secure: boolean(process.env?.SMTP_SECURE || false),
},
ms365: {
tenantId: process.env.MS365_TENANT_ID,
clientId: process.env.MS365_CLIENT_ID,
clientSecret: process.env.MS365_CLIENT_SECRET,
},
},
policyTimes: {
policyPublicationShiftInYears: process.env.POLICY_PUBLICATION_SHIFT ?? 3,
Expand Down

0 comments on commit 1a82a2c

Please sign in to comment.