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

Send email when new passlist entry is added #2087

Open
wants to merge 32 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
a776727
dependencies upgrade
rfontanarosa Nov 12, 2024
2057ebb
added oncreatepasslistentry firebase function
rfontanarosa Nov 12, 2024
24609b4
Merge branch 'master' into rfontanarosa/2077/feature-user-access-gran…
rfontanarosa Nov 14, 2024
592d68a
Merge branch 'master' into rfontanarosa/2077/feature-user-access-gran…
rfontanarosa Nov 19, 2024
c7eabb4
Merge branch 'master' into rfontanarosa/2077/feature-user-access-gran…
rfontanarosa Nov 21, 2024
d8f91f3
Merge branch 'master' into rfontanarosa/2077/feature-user-access-gran…
rfontanarosa Nov 27, 2024
8ec872f
Merge branch 'master' into rfontanarosa/2077/feature-user-access-gran…
rfontanarosa Nov 29, 2024
6ad5180
added mailserver configuration and sendMail function (nodemailer)
rfontanarosa Nov 29, 2024
ff2daeb
moved logic into mail-service
rfontanarosa Dec 3, 2024
a3ab59f
changed mail server structure
rfontanarosa Dec 6, 2024
cfcd2e6
fixed datastore sync issue
rfontanarosa Dec 9, 2024
aa495bf
removed servers sub-collection in favour of servers array, fixed send…
rfontanarosa Dec 9, 2024
dde03bf
Merge branch 'master' into rfontanarosa/2077/feature-user-access-gran…
gino-m Dec 10, 2024
b6465f6
Merge branch 'master' into rfontanarosa/2077/feature-user-access-gran…
rfontanarosa Dec 11, 2024
a86ac53
added mail templating system
rfontanarosa Dec 11, 2024
3852b7b
removed servers[] in favour of server
rfontanarosa Dec 11, 2024
5b5fce2
fixed wrong documentdata type
rfontanarosa Dec 11, 2024
294504d
fixed some error messages and existing checks
rfontanarosa Dec 11, 2024
caec9e5
fixed typo
rfontanarosa Dec 11, 2024
cbaeb03
missing function rename
rfontanarosa Dec 11, 2024
0f594cd
added some comments
rfontanarosa Dec 11, 2024
af407a7
adde other comments
rfontanarosa Dec 11, 2024
328bae0
log error instead of raising an exception when server config doesn't …
rfontanarosa Dec 13, 2024
3ee13e8
added html email sanitizer
rfontanarosa Dec 13, 2024
5dab8a4
fixed missing assignments
rfontanarosa Dec 13, 2024
d376255
Merge branch 'master' into rfontanarosa/2077/feature-user-access-gran…
gino-m Dec 13, 2024
5522d57
fixed typo, missing mail configuration error
rfontanarosa Dec 16, 2024
f05b4ff
Merge branch 'rfontanarosa/2077/feature-user-access-granted-notificat…
rfontanarosa Dec 16, 2024
6990f3e
added some tsdoc, improved mail send error management
rfontanarosa Dec 16, 2024
b86960f
changed some console.error into console.debug
rfontanarosa Dec 16, 2024
abc45a3
Merge branch 'master' into rfontanarosa/2077/feature-user-access-gran…
rfontanarosa Dec 23, 2024
3874319
Merge branch 'master' into rfontanarosa/2077/feature-user-access-gran…
rfontanarosa Jan 7, 2025
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
6 changes: 5 additions & 1 deletion functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,16 @@
"cors": "2.8.5",
"csv-parser": "2.3.3",
"firebase-admin": "12.1.0",
"firebase-functions": "^5.0.1",
"firebase-functions": "^5.1.1",
"google-auth-library": "6.1.3",
"googleapis": "64.0.0",
"http-status-codes": "1.4.0",
"immutable": "^4.3.6",
"jsonstream-ts": "1.3.6",
"module-alias": "^2.2.2",
"nodemailer": "^6.9.16",
"requests": "0.3.0",
"sanitize-html": "^2.13.1",
"ts-node": "^10.9.1"
},
"engines": {
Expand All @@ -53,6 +55,8 @@
"@types/geojson": "^7946.0.14",
"@types/jasmine": "^4.3.5",
"@types/jsonstream": "0.8.30",
"@types/nodemailer": "^6.4.16",
"@types/sanitize-html": "^2.13.0",
"@types/terraformer__wkt": "2.0.0",
"firebase-functions-test": "^3.3.0",
"firebase-tools": "13.6.0",
Expand Down
19 changes: 15 additions & 4 deletions functions/src/common/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
*/

import {Datastore} from './datastore';
import {MailService} from './mail-service';
import {initializeApp, getApp} from 'firebase-admin/app';
import {getFirestore} from 'firebase-admin/firestore';

let datastore: Datastore | undefined;
let mailService: MailService | undefined;

export function initializeFirebaseApp() {
try {
Expand All @@ -29,13 +31,22 @@ export function initializeFirebaseApp() {
}

export function getDatastore(): Datastore {
if (!datastore) {
initializeFirebaseApp();
datastore = new Datastore(getFirestore());
}
if (datastore) return datastore;
initializeFirebaseApp();
datastore = new Datastore(getFirestore());
return datastore;
}

export async function getMailService(): Promise<MailService | undefined> {
if (mailService) return mailService;
const mailServerConfig = await MailService.getMailServerConfig(
getDatastore()
);
if (!mailServerConfig) return;
mailService = new MailService(mailServerConfig);
return mailService;
}

export function resetDatastore() {
datastore = undefined;
}
24 changes: 24 additions & 0 deletions functions/src/common/datastore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
*/
type pseudoGeoJsonGeometry = {
type: string;
coordinates: any;

Check warning on line 34 in functions/src/common/datastore.ts

View workflow job for this annotation

GitHub Actions / Check

Unexpected any. Specify a different type
};

/**
Expand All @@ -39,11 +39,21 @@
*/
export const config = () => 'config';

/**
* Returns the path of passlist entry doc with the specified id.
*/
export const passlistEntry = (entryId: string) => `passlist/${entryId}`;

/**
* Returns the path of integrations doc.
*/
export const integrations = () => config() + '/integrations';

/**
* Returns the path of mail doc.
*/
export const mail = () => config() + '/mail';

/**
* Returns path to survey colection. This is a function for consistency with other path functions.
*/
Expand Down Expand Up @@ -83,6 +93,12 @@
export const submission = (surveyId: string, submissionId: string) =>
submissions(surveyId) + '/' + submissionId;

/**
* Returns the path of template doc with the specified id.
*/
export const mailTemplate = (templateId: string) =>
`${mail()}/templates/${templateId}`;

export class Datastore {
private db_: firestore.Firestore;

Expand Down Expand Up @@ -126,6 +142,10 @@
return this.db_.collection(integrations() + '/propertyGenerators').get();
}

fetchMailConfig() {
return this.fetchDoc_(mail());
}

fetchSurvey(surveyId: string) {
return this.db_.doc(survey(surveyId)).get();
}
Expand All @@ -145,6 +165,10 @@
.get();
}

fetchMailTemplate(templateId: string) {
return this.fetchDoc_(mailTemplate(templateId));
}

fetchSheetsConfig(surveyId: string) {
return this.fetchDoc_(`${survey(surveyId)}/sheets/config`);
}
Expand Down Expand Up @@ -217,7 +241,7 @@
await loiRef.update({[l.properties]: loiDoc[l.properties]});
}

static toFirestoreMap(geometry: any) {

Check warning on line 244 in functions/src/common/datastore.ts

View workflow job for this annotation

GitHub Actions / Check

Unexpected any. Specify a different type
return Object.fromEntries(
Object.entries(geometry).map(([key, value]) => [
key,
Expand All @@ -226,7 +250,7 @@
);
}

static toFirestoreValue(value: any): any {

Check warning on line 253 in functions/src/common/datastore.ts

View workflow job for this annotation

GitHub Actions / Check

Unexpected any. Specify a different type

Check warning on line 253 in functions/src/common/datastore.ts

View workflow job for this annotation

GitHub Actions / Check

Unexpected any. Specify a different type
if (value === null) {
return null;
}
Expand Down Expand Up @@ -254,7 +278,7 @@
*
* @returns GeoJSON geometry object (with geometry as list of lists)
*/
static fromFirestoreMap(geoJsonGeometry: any): any {

Check warning on line 281 in functions/src/common/datastore.ts

View workflow job for this annotation

GitHub Actions / Check

Unexpected any. Specify a different type

Check warning on line 281 in functions/src/common/datastore.ts

View workflow job for this annotation

GitHub Actions / Check

Unexpected any. Specify a different type
const geometryObject = geoJsonGeometry as pseudoGeoJsonGeometry;
if (!geometryObject) {
throw new Error(
Expand All @@ -269,7 +293,7 @@
return geometryObject;
}

static fromFirestoreValue(coordinates: any) {

Check warning on line 296 in functions/src/common/datastore.ts

View workflow job for this annotation

GitHub Actions / Check

Unexpected any. Specify a different type
if (coordinates instanceof GeoPoint) {
// Note: GeoJSON coordinates are in lng-lat order.
return [coordinates.longitude, coordinates.latitude];
Expand All @@ -278,7 +302,7 @@
if (typeof coordinates !== 'object') {
return coordinates;
}
const result = new Array<any>(coordinates.length);

Check warning on line 305 in functions/src/common/datastore.ts

View workflow job for this annotation

GitHub Actions / Check

Unexpected any. Specify a different type

Object.entries(coordinates).map(([i, nestedValue]) => {
const index = Number.parseInt(i);
Expand Down
96 changes: 96 additions & 0 deletions functions/src/common/mail-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Copyright 2024 The Ground Authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import * as nodemailer from 'nodemailer';
import sanitizeHtml from 'sanitize-html';
import {Datastore} from './datastore';

type MailConfig = {
server?: MailServerConfig;
};

type MailServerConfig = {
id: string;
host: string;
port: number;
username: string;
password: string;
rfontanarosa marked this conversation as resolved.
Show resolved Hide resolved
sender?: string;
};

export interface MailServiceEmail {
to: string;
subject: string;
html: string;
rfontanarosa marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Service for sending emails.
*/
export class MailService {
rfontanarosa marked this conversation as resolved.
Show resolved Hide resolved
private transporter_: nodemailer.Transporter;
private sender_: string;

constructor(mailServerConfig: MailServerConfig) {
const {host, port, username, password, sender} = mailServerConfig;

this.sender_ = sender || username;

this.transporter_ = nodemailer.createTransport({
host,
port,
auth: {user: username, pass: password},
sender: this.sender_,
});
}

/**
* Sends an email.
*
* @param email - Email object containing recipient, subject, and body.
*/
async sendMail(email: MailServiceEmail): Promise<void> {
const {html} = email;

const safeHtml = sanitizeHtml(html, {
allowedTags: ['br', 'a'],
allowedAttributes: {
a: ['href'],
},
});

try {
await this.transporter_.sendMail({
from: this.sender_,
...email,
html: safeHtml,
});
} catch (err) {
console.error(err);
}
}

/**
* Retrieves the mail server configuration from the database.
*/
static async getMailServerConfig(
db: Datastore
): Promise<MailServerConfig | undefined> {
const mailConfig = (await db.fetchMailConfig()) as MailConfig;
if (!mailConfig?.server) console.debug('Unable to find mail configuration');
return mailConfig?.server;
}
}
19 changes: 19 additions & 0 deletions functions/src/common/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Copyright 2024 The Ground Authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

export function stringFormat(s: string, ...args: any[]): string {
rfontanarosa marked this conversation as resolved.
Show resolved Hide resolved
return s.replace(/\{(\d+)\}/g, (_, index) => args[index] || `{${index}}`);
}
10 changes: 9 additions & 1 deletion functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,20 @@ import {importGeoJsonCallback} from './import-geojson';
import {exportCsvHandler} from './export-csv';
import {onCall} from 'firebase-functions/v2/https';
import {onCreateLoiHandler} from './on-create-loi';
import {onCreatePasslistEntryHandler} from './on-create-passlist-entry';
import {onWriteJobHandler} from './on-write-job';
import {onWriteLoiHandler} from './on-write-loi';
import {onWriteSubmissionHandler} from './on-write-submission';
import {onWriteSurveyHandler} from './on-write-survey';
import {job, loi, submission, survey} from './common/datastore';
import {job, loi, passlistEntry, submission, survey} from './common/datastore';
import {initializeFirebaseApp} from './common/context';

// Ensure Firebase is initialized.
initializeFirebaseApp();

/** Template for passlist entry write triggers capturing passlist entry id. */
const passlistEntryPathTemplate = passlistEntry('{entryId}');

/** Template for job write triggers capturing survey and job id. */
const jobPathTemplate = job('{surveyId}', '{jobId}');

Expand All @@ -49,6 +53,10 @@ export const profile = {
refresh: onCall(request => handleProfileRefresh(request)),
};

export const onCreatePasslistEntry = functions.firestore
.document(passlistEntryPathTemplate)
.onCreate(onCreatePasslistEntryHandler);

export const importGeoJson = onHttpsRequestAsync(importGeoJsonCallback);

export const exportCsv = onHttpsRequest(exportCsvHandler);
Expand Down
7 changes: 7 additions & 0 deletions functions/src/on-create-loi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ type PropertyGenerator = {
url: string;
};

/**
* Handles the creation of a Location of Interest (LOI) document in Firestore.
* This function is triggered by a Cloud Function on Firestore document creation.
*
* @param snapshot The QueryDocumentSnapshot object containing the created LOI data.
* @param context The EventContext object provided by the Cloud Functions framework.
*/
export async function onCreateLoiHandler(
snapshot: QueryDocumentSnapshot,
context: EventContext
Expand Down
58 changes: 58 additions & 0 deletions functions/src/on-create-passlist-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Copyright 2024 The Ground Authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {EventContext} from 'firebase-functions';
import {QueryDocumentSnapshot} from 'firebase-functions/v1/firestore';
import {getDatastore, getMailService} from './common/context';
import {MailServiceEmail} from './common/mail-service';
import {stringFormat} from './common/utils';

/**
* Handles the creation of a passlist entry.
* This function is triggered by a Cloud Function on Firestore document creation.
*
* @param _ The QueryDocumentSnapshot object (unused in this function).
* @param context The EventContext object provided by the Cloud Functions framework.
*/
export async function onCreatePasslistEntryHandler(
rfontanarosa marked this conversation as resolved.
Show resolved Hide resolved
_: QueryDocumentSnapshot,
context: EventContext
) {
const entryId = context!.params.entryId;

const db = getDatastore();

const template = await db.fetchMailTemplate('passlisted');

if (!template) {
console.debug(
'Passlist notification email template not found in /config/mail/templates/passlisted'
);
return;
}

const {subject, html: htmlBody} = template;

const mail = {
to: entryId,
subject,
html: stringFormat(htmlBody || '', [entryId]),
} as MailServiceEmail;

const mailService = await getMailService();

await mailService?.sendMail(mail);
}
2 changes: 1 addition & 1 deletion lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"dependencies": {
"@google-cloud/firestore": "^7.7.0",
"@ground/proto": "file:../proto",
"firebase-functions": "^5.0.1",
"firebase-functions": "^5.1.1",
"immutable": "^4.3.6",
"long": "^5.2.3",
"protobufjs": "^7.3.0"
Expand Down
Loading
Loading