Skip to content

Commit 6b7fea2

Browse files
committed
feat: add router for drafting emails from mailgun
1 parent bc88b5a commit 6b7fea2

File tree

3 files changed

+171
-1
lines changed

3 files changed

+171
-1
lines changed

server/controllers/webhooks.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type express from 'express';
22

33
import logger from '../lib/logger';
4+
import { draftExpenseFromEmail } from '../lib/mailgun';
45
import { handlePlaidWebhookEvent } from '../lib/plaid/webhooks';
56
import { reportErrorToSentry } from '../lib/sentry';
67
import paymentProviders from '../paymentProviders';
@@ -62,3 +63,14 @@ export async function plaidWebhook(
6263
next(error);
6364
}
6465
}
66+
67+
export async function mailgunWebhook(req: express.Request, res: express.Response): Promise<void> {
68+
try {
69+
await draftExpenseFromEmail(req);
70+
} catch (error) {
71+
logger.error(`mailgun/webhook : ${error.message}`, { body: req.body });
72+
reportErrorToSentry(error, { req, handler: 'WEBHOOK' });
73+
} finally {
74+
res.sendStatus(200);
75+
}
76+
}

server/lib/mailgun.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import config from 'config';
2+
import { Request } from 'express';
3+
import assert from 'node:assert';
4+
import fetch from 'node-fetch';
5+
import { v4 as uuid } from 'uuid';
6+
7+
import { expenseStatus, expenseTypes } from '../constants';
8+
import logger from '../lib/logger';
9+
import { Collective, Expense, UploadedFile, User } from '../models';
10+
11+
type MailgunPostBody = {
12+
Autocrypt: string;
13+
'Content-Language': string;
14+
'Content-Type': string;
15+
Date: string;
16+
'Dkim-Signature': string;
17+
From: string;
18+
'Message-Id': string;
19+
'Mime-Version': '1.0';
20+
Organization: string;
21+
Received: string;
22+
'Return-Path': string;
23+
To: string;
24+
'User-Agent': string;
25+
'X-Envelope-From': string;
26+
'X-Gm-Gg': string;
27+
'X-Gm-Message-State': string;
28+
'X-Google-Dkim-Signature': string;
29+
'X-Google-Smtp-Source': string;
30+
'X-Mailgun-Incoming': 'Yes';
31+
'X-Received': string;
32+
attachments: string;
33+
'body-html': string;
34+
'body-plain': string;
35+
domain: string;
36+
from: string;
37+
'message-headers': string;
38+
'message-url': string;
39+
recipient: string;
40+
sender: string;
41+
signature: string;
42+
'stripped-html': string;
43+
'stripped-signature': string;
44+
'stripped-text': string;
45+
subject: string;
46+
timestamp: string;
47+
token: string;
48+
};
49+
50+
type MailgunAttachment = {
51+
name: string;
52+
'content-type': string;
53+
size: number;
54+
url: string;
55+
};
56+
57+
const processAttachment = async (attachment: MailgunAttachment, user: User): Promise<UploadedFile> => {
58+
const authenticatedUrl = new URL(attachment.url);
59+
authenticatedUrl.password = config.mailgun.apiKey;
60+
authenticatedUrl.username = 'api';
61+
// TODO: Add more validation that the attachments is stored in Mailgun
62+
const response = await fetch(authenticatedUrl.toString());
63+
64+
if (!response.ok) {
65+
throw new Error(`Failed to fetch attachment: ${response.statusText}`);
66+
} else {
67+
const buffer = await response.buffer();
68+
const size = buffer.byteLength;
69+
const mimetype = response.headers.get('Content-Type') || attachment['content-type'] || 'unknown';
70+
const originalname = attachment.name;
71+
const file = {
72+
buffer,
73+
size,
74+
mimetype,
75+
originalname,
76+
};
77+
const uploadedFile = await UploadedFile.upload(file, 'EXPENSE_ITEM', user);
78+
return uploadedFile;
79+
}
80+
};
81+
82+
/**
83+
* Create a draft expense from attachments sent through an email to '[email protected]'
84+
*/
85+
export async function draftExpenseFromEmail(req: Request) {
86+
const email: MailgunPostBody = req.body;
87+
const attachments: MailgunAttachment[] = JSON.parse(email.attachments);
88+
const userEmail = email.sender;
89+
const collectiveSlug = email.recipient.split('@')?.[0];
90+
91+
// TODO: Add more validation that the email is coming from Mailgun
92+
93+
if (attachments.length === 0) {
94+
logger.info(`No attachments found for email from ${userEmail}`);
95+
return;
96+
}
97+
const existingCreatedExpense = await Expense.findOne({ where: { data: { emailId: email['Message-Id'] } } });
98+
if (existingCreatedExpense) {
99+
logger.info(
100+
`Expense already created for email from ${userEmail} with Message-Id ${email['Message-Id']}, skipping creation.`,
101+
);
102+
return;
103+
}
104+
105+
const collective = await Collective.findOne({ where: { slug: collectiveSlug } });
106+
assert(collective, `No collective found for slug ${collectiveSlug}`);
107+
const user = await User.findOne({ where: { email: userEmail } });
108+
assert(user, `No user found for email ${userEmail}`);
109+
const fromCollective = await user.getCollective();
110+
// TODO: Add policy to allow collective admins to restrict who can submit expenses by email
111+
112+
const draftKey = process.env.OC_ENV === 'e2e' || process.env.OC_ENV === 'ci' ? 'draft-key' : uuid();
113+
114+
const items = [];
115+
for (const attachment of attachments) {
116+
const uploadedFile = await processAttachment(attachment, user);
117+
// TODO: Use AI to parse amount and description
118+
items.push({
119+
id: uuid(),
120+
url: uploadedFile.url,
121+
amount: 1,
122+
__isNew: true,
123+
description: uploadedFile.fileName,
124+
});
125+
}
126+
127+
// Create the expense
128+
const expense = await Expense.create({
129+
// TODO: Is there a simple way to decide if it's a receipt or an invoice?
130+
type: expenseTypes.RECEIPT,
131+
status: expenseStatus.DRAFT,
132+
CollectiveId: collective.id,
133+
FromCollectiveId: fromCollective.id,
134+
lastEditedById: user.id,
135+
UserId: user.id,
136+
currency: collective.currency,
137+
incurredAt: new Date(),
138+
description: email.subject || 'Expense submitted by email',
139+
amount: 1,
140+
data: {
141+
items,
142+
draftKey,
143+
notify: true,
144+
email,
145+
emailId: email['Message-Id'],
146+
},
147+
});
148+
logger.info(`Created draft expense #${expense.id} for email from ${userEmail}`);
149+
}

server/routes.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@ import LegalDocumentsController from './controllers/legal-documents';
1919
import * as email from './controllers/services/email';
2020
import * as transferwise from './controllers/transferwise';
2121
import * as users from './controllers/users';
22-
import { paypalWebhook, plaidWebhook, stripeWebhook, transferwiseWebhook } from './controllers/webhooks';
22+
import {
23+
mailgunWebhook,
24+
paypalWebhook,
25+
plaidWebhook,
26+
stripeWebhook,
27+
transferwiseWebhook,
28+
} from './controllers/webhooks';
2329
import { getGraphqlCacheProperties } from './graphql/cache';
2430
import graphqlSchemaV1 from './graphql/v1/schema';
2531
import graphqlSchemaV2 from './graphql/v2/schema';
@@ -341,6 +347,9 @@ export default async (app: express.Application) => {
341347
app.post('/webhooks/transferwise', transferwiseWebhook); // when it gets a new subscription invoice
342348
app.post('/webhooks/paypal/:hostId?', paypalWebhook);
343349
app.post('/webhooks/plaid', plaidWebhook);
350+
// Expense email creation inbox route. Needs to come before the sanitizer middleware.
351+
app.all('/webhooks/mailgun', mailgunWebhook);
352+
344353
app.get('/connected-accounts/:service/callback', noCache, authentication.authenticateServiceCallback); // oauth callback
345354
app.delete(
346355
'/connected-accounts/:service/disconnect/:collectiveId',

0 commit comments

Comments
 (0)