|
| 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 | +} |
0 commit comments