Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support Microsoft Graph API for emails #1628

Merged
merged 7 commits into from
Jan 28, 2025
Merged
Show file tree
Hide file tree
Changes from 2 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
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
58 changes: 47 additions & 11 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";

@Module({
imports: [
Expand All @@ -51,18 +54,51 @@ import { LoggerModule } from "./loggers/logger.module";
LogbooksModule,
EventEmitterModule.forRoot(),
MailerModule.forRootAsync({
imports: [ConfigModule],
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 @@ -77,7 +113,7 @@ import { LoggerModule } from "./loggers/logger.module";
},
};
},
inject: [ConfigService],
inject: [ConfigService, HttpService],
}),
MongooseModule.forRootAsync({
imports: [ConfigModule],
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 @@ -210,11 +210,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
Loading