Skip to content

Commit aa67647

Browse files
committed
adding resend as net notification adapter
1 parent 7a9d498 commit aa67647

File tree

5 files changed

+173
-22
lines changed

5 files changed

+173
-22
lines changed

lib/notification/adapter/resend.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
* Copyright (c) 2026 by Christian Kellner.
3+
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
4+
*/
5+
6+
import { Resend } from 'resend';
7+
import path from 'path';
8+
import fs from 'fs';
9+
import Handlebars from 'handlebars';
10+
import { markdown2Html } from '../../services/markdown.js';
11+
import { getDirName, normalizeImageUrl } from '../../utils.js';
12+
13+
const __dirname = getDirName();
14+
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
15+
const emailTemplate = Handlebars.compile(template);
16+
17+
const mapListings = (serviceName, jobKey, listings) =>
18+
listings.map((l) => {
19+
const image = normalizeImageUrl(l.image);
20+
return {
21+
title: l.title || '',
22+
link: l.link || '',
23+
address: l.address || '',
24+
size: l.size || '',
25+
price: l.price || '',
26+
image,
27+
hasImage: Boolean(image),
28+
serviceName,
29+
jobKey,
30+
};
31+
});
32+
33+
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
34+
const { apiKey, receiver, from } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
35+
36+
const to = receiver
37+
.trim()
38+
.split(',')
39+
.map((r) => r.trim())
40+
.filter(Boolean);
41+
42+
const resend = new Resend(apiKey);
43+
44+
const listings = mapListings(serviceName, jobKey, newListings);
45+
46+
const html = emailTemplate({
47+
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
48+
numberOfListings: listings.length,
49+
listings,
50+
});
51+
52+
const { error } = await resend.emails.send({
53+
from,
54+
to,
55+
subject: `Fredy found ${listings.length} new listing(s) for ${serviceName}`,
56+
html,
57+
});
58+
59+
if (!error) {
60+
return Promise.resolve();
61+
} else {
62+
return Promise.reject(error.message);
63+
}
64+
};
65+
66+
export const config = {
67+
id: 'resend',
68+
name: 'Resend',
69+
description: 'Resend is being used to send new listings via mail.',
70+
readme: markdown2Html('lib/notification/adapter/resend.md'),
71+
fields: {
72+
apiKey: {
73+
type: 'text',
74+
label: 'Api Key',
75+
description: 'The Resend API key used to send emails.',
76+
},
77+
receiver: {
78+
type: 'email',
79+
label: 'Receiver Email',
80+
description: 'Comma-separated email addresses Fredy will send notifications to.',
81+
},
82+
from: {
83+
type: 'email',
84+
label: 'Sender Email',
85+
description: 'The verified email address or domain you send from in Resend.',
86+
},
87+
},
88+
};

lib/notification/adapter/resend.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
### Resend Adapter
2+
3+
Resend is a modern email delivery service that Fredy can use to send notifications.
4+
5+
Setup:
6+
- Create a Resend account: https://resend.com/
7+
- Create an API key and add it to Fredy's configuration.
8+
- Choose the sender address (e.g., you@yourdomain.com). Verify the domain (https://resend.com/domains/) in Resend before using it.
9+
- Optional for local testing: you can use `onboarding@resend.dev`, but Resend may restrict who you can send to when using test domains.
10+
11+
Multiple recipients:
12+
- Separate email addresses with commas (e.g., some@email.com, someOther@email.com).
13+
14+
Notes & Troubleshooting:
15+
- Ensure the `from` address is verified or belongs to a verified domain in Resend.
16+
- If emails don't arrive, check your spam folder and Resend dashboard logs.
17+
- The template displays listing images via their public URLs; make sure images are reachable.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "fredy",
3-
"version": "19.5.1",
3+
"version": "19.5.2",
44
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
55
"scripts": {
66
"prepare": "husky",
@@ -90,6 +90,7 @@
9090
"react-range-slider-input": "^3.3.2",
9191
"react-router": "7.13.0",
9292
"react-router-dom": "7.13.0",
93+
"resend": "^6.9.2",
9394
"restana": "5.1.0",
9495
"semver": "^7.7.4",
9596
"serve-static": "2.2.1",

ui/src/views/jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -197,27 +197,6 @@ export default function NotificationAdapterMutator({
197197
</div>
198198
}
199199
>
200-
{validationMessage != null && (
201-
<Banner
202-
fullMode={false}
203-
type="danger"
204-
closeIcon={null}
205-
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Error</div>}
206-
style={{ marginBottom: '1rem' }}
207-
description={<p dangerouslySetInnerHTML={{ __html: validationMessage }} />}
208-
/>
209-
)}
210-
{successMessage != null && (
211-
<Banner
212-
fullMode={false}
213-
type="success"
214-
closeIcon={null}
215-
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Yay!</div>}
216-
style={{ marginBottom: '1rem' }}
217-
description={<p dangerouslySetInnerHTML={{ __html: successMessage }} />}
218-
/>
219-
)}
220-
221200
{description != null ? (
222201
<p>{description}</p>
223202
) : (
@@ -264,6 +243,28 @@ export default function NotificationAdapterMutator({
264243
<br />
265244
{selectedAdapter.readme != null && <Help readme={selectedAdapter.readme} />}
266245
<br />
246+
247+
{validationMessage != null && (
248+
<Banner
249+
fullMode={false}
250+
type="danger"
251+
closeIcon={null}
252+
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Error</div>}
253+
style={{ marginBottom: '1rem' }}
254+
description={<p dangerouslySetInnerHTML={{ __html: validationMessage }} />}
255+
/>
256+
)}
257+
{successMessage != null && (
258+
<Banner
259+
fullMode={false}
260+
type="success"
261+
closeIcon={null}
262+
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Yay!</div>}
263+
style={{ marginBottom: '1rem' }}
264+
description={<p dangerouslySetInnerHTML={{ __html: successMessage }} />}
265+
/>
266+
)}
267+
267268
{getFieldsFor(selectedAdapter)}
268269
</>
269270
)}

yarn.lock

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1616,6 +1616,11 @@
16161616
"@sendgrid/client" "^8.1.5"
16171617
"@sendgrid/helpers" "^8.0.0"
16181618

1619+
"@stablelib/base64@^1.0.0":
1620+
version "1.0.1"
1621+
resolved "https://registry.yarnpkg.com/@stablelib/base64/-/base64-1.0.1.tgz#bdfc1c6d3a62d7a3b7bbc65b6cce1bb4561641be"
1622+
integrity sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==
1623+
16191624
"@tiptap/core@^3.10.7":
16201625
version "3.16.0"
16211626
resolved "https://registry.npmjs.org/@tiptap/core/-/core-3.16.0.tgz"
@@ -3608,6 +3613,11 @@ fast-levenshtein@^2.0.6:
36083613
resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz"
36093614
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
36103615

3616+
fast-sha256@^1.3.0:
3617+
version "1.3.0"
3618+
resolved "https://registry.yarnpkg.com/fast-sha256/-/fast-sha256-1.3.0.tgz#7916ba2054eeb255982608cccd0f6660c79b7ae6"
3619+
integrity sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==
3620+
36113621
fd-slicer@~1.1.0:
36123622
version "1.1.0"
36133623
resolved "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz"
@@ -5951,6 +5961,11 @@ possible-typed-array-names@^1.0.0:
59515961
resolved "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz"
59525962
integrity sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==
59535963

5964+
postal-mime@2.7.3:
5965+
version "2.7.3"
5966+
resolved "https://registry.yarnpkg.com/postal-mime/-/postal-mime-2.7.3.tgz#358d92192656a262568ffc7a441a713131aa1272"
5967+
integrity sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==
5968+
59545969
postcss@^8.5.6:
59555970
version "8.5.6"
59565971
resolved "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz"
@@ -6621,6 +6636,14 @@ require-directory@^2.1.1:
66216636
resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz"
66226637
integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==
66236638

6639+
resend@^6.9.2:
6640+
version "6.9.2"
6641+
resolved "https://registry.yarnpkg.com/resend/-/resend-6.9.2.tgz#0aae7681060c535915ce6cca97740950bf33b75a"
6642+
integrity sha512-uIM6CQ08tS+hTCRuKBFbOBvHIGaEhqZe8s4FOgqsVXSbQLAhmNWpmUhG3UAtRnmcwTWFUqnHa/+Vux8YGPyDBA==
6643+
dependencies:
6644+
postal-mime "2.7.3"
6645+
svix "1.84.1"
6646+
66246647
resolve-from@^4.0.0:
66256648
version "4.0.0"
66266649
resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz"
@@ -7026,6 +7049,14 @@ split-on-first@^3.0.0:
70267049
resolved "https://registry.npmjs.org/split-on-first/-/split-on-first-3.0.0.tgz"
70277050
integrity sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==
70287051

7052+
standardwebhooks@1.0.0:
7053+
version "1.0.0"
7054+
resolved "https://registry.yarnpkg.com/standardwebhooks/-/standardwebhooks-1.0.0.tgz#5faa23ceacbf9accd344361101d9e3033b64324f"
7055+
integrity sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==
7056+
dependencies:
7057+
"@stablelib/base64" "^1.0.0"
7058+
fast-sha256 "^1.3.0"
7059+
70297060
statuses@2.0.1:
70307061
version "2.0.1"
70317062
resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz"
@@ -7254,6 +7285,14 @@ supports-preserve-symlinks-flag@^1.0.0:
72547285
resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz"
72557286
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
72567287

7288+
svix@1.84.1:
7289+
version "1.84.1"
7290+
resolved "https://registry.yarnpkg.com/svix/-/svix-1.84.1.tgz#9e086455acf01143fe0f90c5f618393c3e3591cf"
7291+
integrity sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==
7292+
dependencies:
7293+
standardwebhooks "1.0.0"
7294+
uuid "^10.0.0"
7295+
72577296
tar-fs@^2.0.0:
72587297
version "2.1.3"
72597298
resolved "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz"
@@ -7598,6 +7637,11 @@ utility-types@^3.10.0:
75987637
resolved "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz"
75997638
integrity sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==
76007639

7640+
uuid@^10.0.0:
7641+
version "10.0.0"
7642+
resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294"
7643+
integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==
7644+
76017645
vfile-message@^4.0.0:
76027646
version "4.0.3"
76037647
resolved "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz"

0 commit comments

Comments
 (0)