fix: functions

pull/89/head
Pierre-Louis Guhur 1 year ago
parent f817ce7b0d
commit 33de97d84f

@ -49,7 +49,7 @@ To connect your application with Mailgun, you need to add the environment variab
- `MAILGUN_DOMAIN`,
- `MAILGUN_URL`,
- `FROM_EMAIL_ADDRESS`,
- `CONTACT_TO_EMAIL_ADDRESS`.
- `REPLY_TO_EMAIL_ADDRESS`.
You can add the environment variables on an `.env` file or directly on [Netlify](https://docs.netlify.com/configure-builds/environment-variables/).

@ -1,7 +1,6 @@
import { isValidElement } from 'react';
import { IconProp } from '@fortawesome/fontawesome-svg-core';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Row, Col, Button } from 'reactstrap';
import {IconProp} from '@fortawesome/fontawesome-svg-core';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {Row, Col, Button} from 'reactstrap';
interface ButtonProps {
children?: React.ReactNode;
@ -31,7 +30,7 @@ const ButtonWithIcon = ({
} else if ((icon || customIcon) && position === 'right') {
return (
<Button {...props}>
<Row className="gx-2 align-items-end">
<Row className="gx-2 align-items-end justify-content-between">
<Col className="col-auto">{children}</Col>
<Col className="col-auto">
{customIcon ? customIcon : <FontAwesomeIcon icon={icon} />}

@ -2,7 +2,8 @@
* Contain a button with a content that can be copied
*/
import {faCopy} from '@fortawesome/free-solid-svg-icons';
import Button from '@components/Button';
import {Button} from 'reactstrap';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
interface ButtonCopyInterface {
text: string;
@ -11,12 +12,15 @@ interface ButtonCopyInterface {
const ButtonCopy = ({text, content}: ButtonCopyInterface) => {
return (<Button
className="bg-white shadow-lg border-black border-3"
position="right"
icon={faCopy}
onClick={() => navigator.clipboard.writeText(content)}
>
{text}
className="bg-white text-black my-2 shadow-lg border-dark border py-3 px-4 border-3 justify-content-between"
onClick={() => navigator.clipboard.writeText(content)}>
<div className="gx-2 align-items-end justify-content-between">
<div>{text}</div>
<div>
<FontAwesomeIcon icon={faCopy} />
</div>
</div>
</Button >)

@ -12,7 +12,7 @@ const Error = ({msg}) => {
return (
<Container className="full-height-container">
<h4>{t("common.error")}</h4>
<div className="text-black text-center shadow-lg border-dark border-2 p-3 my-3 bg-white">{msg}</div>
<div className="text-black text-center shadow-lg border-dark border border-2 p-3 my-3 bg-white">{msg}</div>
<a
href={`mailto:${CONTACT_MAIL}?subject=[HELP]`}

@ -4,11 +4,13 @@ import {CSSProperties, useEffect, useState} from 'react';
import {faArrowRight} from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import Button from '@components/Button';
import ButtonCopy from '@components/ButtonCopy';
import Share from '@components/Share';
import ErrorMessage from '@components/Error';
import AdminModalEmail from '@components/admin/AdminModalEmail';
import {ElectionPayload, ErrorPayload} from '@services/api';
import {useAppContext} from '@services/context';
import {getUrlVote, getUrlResults} from '@services/routes';
import urne from '../public/urne.svg'
import star from '../public/star.svg'
import {Container} from 'reactstrap';
@ -19,20 +21,82 @@ export interface WaitingBallotInterface {
error?: ErrorPayload;
}
interface InfoElectionInterface extends WaitingBallotInterface {
display: string;
}
const InfoElection = ({election, error, display}: InfoElectionInterface) => {
const {t} = useTranslation();
export default ({election, error}: WaitingBallotInterface) => {
const {setApp} = useAppContext();
const [modal, setModal] = useState(false);
const toggleModal = () => setModal(m => !m);
console.log("election", election)
console.log("error", error)
if (!election) return null;
const urlVote = getUrlVote(election.id)
const urlResults = getUrlResults(election.id)
return (
<div style={{
display: display,
transition: "display 2s",
}}
className="d-flex flex-column align-items-center"
>
{error && error.detail ?
<ErrorMessage msg={error.detail[0].msg} /> : null}
{election && election.id ?
<>
<h4 className="text-center">
{t('admin.success-election')}
</h4>
{election && election.private ?
<h5 className="text-center">
{t('admin.success-emails')}
</h5>
: <div className="d-grid w-100">
<ButtonCopy
text={t('admin.modal-copy-vote')}
content={urlVote}
/>
<ButtonCopy
text={t('admin.modal-copy-vote')}
content={urlResults}
/>
</div>}
<Button
customIcon={<FontAwesomeIcon icon={faArrowRight} />}
position="right"
color="secondary"
outline={true}
onClick={toggleModal}
className="mt-3 py-3 px-4"
>
{t('admin.go-to-admin')}
</Button>
<Share title={t('common.share-short')} />
<AdminModalEmail
toggle={toggleModal}
isOpen={modal}
electionId={election.id}
adminToken={election.admin}
/>
</> : null}
</div>
)
}
export default ({election, error}: WaitingBallotInterface) => {
const {setApp} = useAppContext();
const [urneProperties, setUrne] = useState<CSSProperties>({width: 0, height: 0, marginBottom: 0});
const [starProperties, setStar] = useState<CSSProperties>({width: 0, height: 0, marginLeft: 100, marginBottom: 0});
const [urneContainerProperties, setUrneContainer] = useState<CSSProperties>({height: "100vh"});
const [electionProperties, setElection] = useState<CSSProperties>({display: "none"});
const {t} = useTranslation();
useEffect(() => {
setApp({footer: false});
@ -134,44 +198,6 @@ export default ({election, error}: WaitingBallotInterface) => {
/>
</div>
</div>
<div style={{
display: electionProperties.display,
transition: "display 2s",
}}
className="d-flex flex-column align-items-center"
>
{error && error.detail ?
<ErrorMessage msg={error.detail[0].msg} /> : null}
{election && election.id ?
<>
<h4 className="text-center">
{t('admin.success-election')}
</h4>
{election && election.private ?
<h5 className="text-center">
{t('admin.success-emails')}
</h5>
: null}
<Button
customIcon={<FontAwesomeIcon icon={faArrowRight} />}
position="right"
color="secondary"
outline={true}
onClick={toggleModal}
className="mt-3 py-3 px-4"
>
{t('admin.go-to-admin')}
</Button>
<Share title={t('common.share-short')} />
<AdminModalEmail
toggle={toggleModal}
isOpen={modal}
electionId={election.id}
adminToken={election.admin}
/>
</> : null}
</div>
<InfoElection election={election} error={error} display={electionProperties.display} />
</Container >)
}

@ -19,7 +19,7 @@ import Grades from './Grades';
import Private from './Private';
import {useElection, ElectionContextInterface} from '@services/ElectionContext';
import {createElection, ElectionPayload} from '@services/api';
import {getUrlVote, getUrlResult} from '@services/routes';
import {getUrlVote, getUrlResults} from '@services/routes';
import {GradeItem, CandidateItem} from '@services/type';
import {sendInviteMails} from '@services/mail';
@ -90,7 +90,7 @@ const submitElection = (
throw Error('Can not send invite emails');
}
const urlVotes = payload.tokens.map((token: string) => getUrlVote(id.toString(), token));
const urlResult = getUrlResult(id.toString());
const urlResult = getUrlResults(id.toString());
await sendInviteMails(
election.emails,
election.name,

@ -23,7 +23,7 @@ const Header = () => {
<Image
onClick={toggle}
role="button"
className="btn_menu"
className="btn_menu d-md-none"
src={openMenuIcon}
alt="open menu icon"
height="50"

@ -0,0 +1,132 @@
import fs from 'fs';
import {Handler} from "@netlify/functions";
import formData from 'form-data';
import Mailgun from 'mailgun.js';
import Handlebars from 'handlebars';
import {i18n} from '../../next-i18next.config.js';
import i18next from 'i18next';
import {MailgunMessageData} from "mailgun.js/interfaces/Messages.js";
const {
MAILGUN_API_KEY,
MAILGUN_DOMAIN,
MAILGUN_URL,
FROM_EMAIL_ADDRESS,
REPLY_TO_EMAIL_ADDRESS,
} = process.env;
const mailgun = new Mailgun(formData);
const mg = mailgun.client({
username: 'api',
key: MAILGUN_API_KEY,
url: MAILGUN_URL
});
const txtStr = fs.readFileSync(__dirname + "/invite.txt").toString()
i18next.init({
fallbackLng: 'en',
ns: ['file1', 'file2'],
defaultNS: 'file1',
debug: true
}, (err, t) => {
if (err) return console.log('something went wrong loading', err);
t("foo");
});
Handlebars.registerHelper('i18n',
(str: string): string => {
return (i18next != undefined ? i18next.t(str) : str);
}
);
interface RequestPayload {
recipients: {[email: string]: {[key: string]: string}};
options: any;
locale: string;
action: "invite" | "admin";
}
const handler: Handler = async (event) => {
/**
* Send a mail using Mailgun
*/
if (event.httpMethod !== 'POST') {
return {
statusCode: 405,
body: 'Method Not Allowed',
headers: {Allow: 'POST'},
};
}
const {recipients, options, action, locale} = JSON.parse(event.body) as RequestPayload;
if (!recipients) {
return {
statusCode: 422,
body: 'The list of recipients is missing.',
};
}
if (!action || !["invite", "admin"].includes(action)) {
return {
statusCode: 422,
body: 'Unknown action.',
};
}
if (!locale || !["fr", "gb"].includes(locale)) {
return {
statusCode: 422,
body: 'Unknown locale.',
};
}
// TODO setup locale
const htmlTemplate = "FOO"; //fs.readFileSync(`${__dirname}/${action}.txt`).toString();
const htmlContent = Handlebars.compile(htmlTemplate);
const payload: MailgunMessageData = {
// from: `${i18next.t("Mieux Voter")} <mailgun@mg.app.mieuxvoter.fr>`,
from: FROM_EMAIL_ADDRESS || '"Mieux Voter" <postmaster@mg.app.mieuxvoter.fr>',
to: Object.keys(recipients),
subject: i18next.t(`emails.subject-${action}`),
html: htmlContent({}),
'h:Reply-To': REPLY_TO_EMAIL_ADDRESS || 'app@mieuxvoter.fr',
'o:tag': action,
'o:require-tls': true,
'o:dkim': true,
'recipient-variables': JSON.stringify(recipients),
};
console.log(payload)
try {
const res = await mg.messages.create(MAILGUN_DOMAIN, payload)
return {
statusCode: 200,
body: JSON.stringify(
{
...res,
"status": `${recipients.length} emails were sent.`,
}
)
}
} catch {
return {
statusCode: 422,
body: JSON.stringify(
{
"status": "can not send the message"
}
)
}
};
};
export {handler};

@ -4,7 +4,7 @@
{{#i18n 'email.why'}}This email was sent to you because your email was filled out to participate in the vote on the subject:{{/i18n}}
{{ title }}
%recipient.title%
{{#i18n 'email.linkVote' }}The link for the vote is as follows:{{/i18n}}

@ -1,145 +0,0 @@
import fs from 'fs';
// const Mailgun = require('mailgun.js');
// const formData = require('form-data');
import dotenv from 'dotenv';
// const Handlebars = require("handlebars");
dotenv.config();
// const {
// MAILGUN_API_KEY,
// MAILGUN_DOMAIN,
// MAILGUN_URL,
// FROM_EMAIL_ADDRESS,
// CONTACT_TO_EMAIL_ADDRESS,
// } = process.env;
// const mailgun = new Mailgun(formData);
// const mg = mailgun.client({
// username: 'api',
// key: MAILGUN_API_KEY,
// url: 'https://api.eu.mailgun.net',
// });
const success = {
statusCode: 200,
body: "Your message was sent successfully! We'll be in touch.",
};
const err = {
statusCode: 422,
body: "Can't send message",
};
// setup i18n
// i18next.use(Backend).init({
// lng: "fr",
// ns: ["emailInvite", "common"],
// defaultNS: "emailInvite",
// fallbackNS: "common",
// debug: false,
// fallbackLng: ["fr"],
// backend: {
// backends: [FSBackend, HttpApi],
// backendOptions: [{ loadPath: "/public/locales/{{lng}}/{{ns}}.json" }, {}],
// },
// });
// setup the template engine
// See https://github.com/UUDigitalHumanitieslab/handlebars-i18next
// function extend(target, ...sources) {
// sources.forEach((source) => {
// if (source)
// for (let key in source) {
// target[key] = source[key];
// }
// });
// return target;
// }
// Handlebars.registerHelper("i18n", function (key, { hash, data, fn }) {
// let parsed = {};
// const jsonKeys = [
// "lngs",
// "fallbackLng",
// "ns",
// "postProcess",
// "interpolation",
// ];
// jsonKeys.forEach((key) => {
// if (hash[key]) {
// parsed[key] = JSON.parse(hash[key]);
// delete hash[key];
// }
// });
// let options = extend({}, data.root.i18next, hash, parsed, {
// returnObjects: false,
// });
// let replace = (options.replace = extend({}, this, options.replace, hash));
// delete replace.i18next; // may creep in if this === data.root
// if (fn) options.defaultValue = fn(replace);
// return new Handlebars.SafeString(i18next.t(key, options));
// });
// const txtStr = fs.readFileSync(__dirname + "/invite.txt").toString();
const txtStr = {
en: fs.readFileSync(__dirname + '/invite-en.txt').toString(),
fr: fs.readFileSync(__dirname + '/invite-fr.txt').toString(),
};
const txtTemplate = {
en: '', // Handlebars.compile(txtStr.en),
fr: '', // Handlebars.compile(txtStr.fr),
};
const htmlStr = {
en: fs.readFileSync(__dirname + '/invite-en.html').toString(),
fr: fs.readFileSync(__dirname + '/invite-fr.html').toString(),
};
const htmlTemplate = {
en: '', // Handlebars.compile(htmlStr.en),
fr: '', // Handlebars.compile(htmlStr.fr),
};
const sendMail = async (event) => {
if (event.httpMethod !== 'POST') {
return {
statusCode: 405,
body: 'Method Not Allowed',
headers: { Allow: 'POST' },
};
}
const data = JSON.parse(event.body);
if (!data.recipientVariables || !data.title || !data.locale) {
return {
statusCode: 422,
body: 'Recipient variables and title are required.',
};
}
// i18next.changeLanguage(data.locale);
const templateData = {
title: data.title,
};
const mailgunData = {
// from: `${i18next.t("Mieux Voter")} <mailgun@mg.app.mieuxvoter.fr>`,
from: '"Mieux Voter" <postmaster@mg.app.mieuxvoter.fr>',
to: Object.keys(data.recipientVariables),
text: '', // txtTemplate.fr(templateData),
html: '', // htmlTemplate.fr(templateData),
subject: data.title,
'h:Reply-To': 'app@mieuxvoter.fr',
'recipient-variables': JSON.stringify(data.recipientVariables),
};
return true;
// const res = mg.messages
// .create('mg.app.mieuxvoter.fr', mailgunData)
// .then((msg) => {
// return success;
// }) // logs response data
// .catch((err) => {
// console.log(err);
// return success;
// }); // logs any error
//
// return res;
};
exports.handler = sendMail;

1151
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -19,6 +19,7 @@
"@fortawesome/free-regular-svg-icons": "^6.2.0",
"@fortawesome/free-solid-svg-icons": "^6.2.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"@netlify/functions": "^1.3.0",
"@types/react": "^18.0.24",
"bootstrap": "^5.2.2",
"bootstrap-scss": "^5.2.2",
@ -27,8 +28,7 @@
"embla-carousel-react": "^7.0.4",
"eslint-config-next": "^13.0.0",
"framer-motion": "^7.6.4",
"i18next": "^22.0.3",
"mailgun.js": "^3.3.2",
"i18next": "^22.0.6",
"next": "^13.0.0",
"next-i18next": "^12.1.0",
"react": "^18.2.0",
@ -41,9 +41,15 @@
"typescript": "^4.8.4"
},
"devDependencies": {
"@sendgrid/mail": "^7.7.0",
"@types/node": "18.11.9",
"cheerio": "^1.0.0-rc.12",
"eslint": "^8.11.0",
"eslint-plugin-react": "^7.29.3"
"eslint-plugin-react": "^7.29.3",
"form-data": "^4.0.0",
"handlebars": "^4.7.7",
"mailgun.js": "^8.0.6",
"postmark": "^3.0.14"
},
"prettier": {
"trailingComma": "es5",

@ -6,17 +6,19 @@ import {getWindowUrl} from './utils';
export const CREATE_ELECTION = '/admin/new/';
export const getUrlVote = (electionId: string, token: string): URL => {
export const getUrlVote = (electionId: string | number, token?: string): URL => {
const origin = getWindowUrl();
return new URL(`/vote/${electionId}/${token}`, origin);
if (token)
return new URL(`/vote/${electionId}/${token}`, origin);
return new URL(`/vote/${electionId}`, origin);
}
export const getUrlResult = (electionId: string): URL => {
export const getUrlResults = (electionId: string | number): URL => {
const origin = getWindowUrl();
return new URL(`/result/${electionId}`, origin);
}
export const getUrlAdmin = (electionId: string, adminToken: string): URL => {
export const getUrlAdmin = (electionId: string | number, adminToken: string): URL => {
const origin = getWindowUrl();
return new URL(`/admin/${electionId}?t=${adminToken}`, origin);
}

@ -26,19 +26,3 @@ export const getWindowUrl = (): string => {
: 'http://localhost';
}
export const getUrlVote = (electionId: string, token: string): URL => {
const origin = getWindowUrl();
return new URL(`/vote/${electionId}/${token}`, origin);
}
export const getUrlResult = (electionId: string): URL => {
const origin = getWindowUrl();
return new URL(`/result/${electionId}`, origin);
}
export const getUrlConfirm = (electionId: string): URL => {
const origin = getWindowUrl();
return new URL(`/admin/confirm/${electionId}`, origin);
}

@ -0,0 +1,19 @@
#! /usr/bin/env bash
# This file tests the netlify function for sending emails
# Check if the port is already used or not
is_using=$(lsof -i:9999 | awk -F ' ' '{ print $1 }')
if [ -z "$is_using" ]; then
echo "Starting a server on port 9999";
netlify functions:serve --port 9999 &
elif ! [[ "$is_using" =~ .*"node".* ]]; then
echo "$is_using"
echo "The port 9999 is already used and not by us :-("
exit 1;
else
echo "The server is running."
fi
netlify functions:invoke --port 9999 send-emails --payload '{"recipients"
: {"pierrelouisguhur@gmail.com": {"title": "Test", "adminUrl": "foo"}}, "action": "invite", "locale": "gb"}'
Loading…
Cancel
Save