diff --git a/lib/notification/adapter/resend.js b/lib/notification/adapter/resend.js new file mode 100755 index 0000000..983496f --- /dev/null +++ b/lib/notification/adapter/resend.js @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { Resend } from 'resend'; +import path from 'path'; +import fs from 'fs'; +import Handlebars from 'handlebars'; +import { markdown2Html } from '../../services/markdown.js'; +import { getDirName, normalizeImageUrl } from '../../utils.js'; + +const __dirname = getDirName(); +const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8'); +const emailTemplate = Handlebars.compile(template); + +const mapListings = (serviceName, jobKey, listings) => + listings.map((l) => { + const image = normalizeImageUrl(l.image); + return { + title: l.title || '', + link: l.link || '', + address: l.address || '', + size: l.size || '', + price: l.price || '', + image, + hasImage: Boolean(image), + serviceName, + jobKey, + }; + }); + +export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => { + const { apiKey, receiver, from } = notificationConfig.find((adapter) => adapter.id === config.id).fields; + + const to = receiver + .trim() + .split(',') + .map((r) => r.trim()) + .filter(Boolean); + + const resend = new Resend(apiKey); + + const listings = mapListings(serviceName, jobKey, newListings); + + const html = emailTemplate({ + serviceName: `Job: (${jobKey}) | Service: ${serviceName}`, + numberOfListings: listings.length, + listings, + }); + + const { error } = await resend.emails.send({ + from, + to, + subject: `Fredy found ${listings.length} new listing(s) for ${serviceName}`, + html, + }); + + if (!error) { + return Promise.resolve(); + } else { + return Promise.reject(error.message); + } +}; + +export const config = { + id: 'resend', + name: 'Resend', + description: 'Resend is being used to send new listings via mail.', + readme: markdown2Html('lib/notification/adapter/resend.md'), + fields: { + apiKey: { + type: 'text', + label: 'Api Key', + description: 'The Resend API key used to send emails.', + }, + receiver: { + type: 'email', + label: 'Receiver Email', + description: 'Comma-separated email addresses Fredy will send notifications to.', + }, + from: { + type: 'email', + label: 'Sender Email', + description: 'The verified email address or domain you send from in Resend.', + }, + }, +}; diff --git a/lib/notification/adapter/resend.md b/lib/notification/adapter/resend.md new file mode 100644 index 0000000..5653087 --- /dev/null +++ b/lib/notification/adapter/resend.md @@ -0,0 +1,17 @@ +### Resend Adapter + +Resend is a modern email delivery service that Fredy can use to send notifications. + +Setup: +- Create a Resend account: https://resend.com/ +- Create an API key and add it to Fredy's configuration. +- Choose the sender address (e.g., you@yourdomain.com). Verify the domain (https://resend.com/domains/) in Resend before using it. +- Optional for local testing: you can use `onboarding@resend.dev`, but Resend may restrict who you can send to when using test domains. + +Multiple recipients: +- Separate email addresses with commas (e.g., some@email.com, someOther@email.com). + +Notes & Troubleshooting: +- Ensure the `from` address is verified or belongs to a verified domain in Resend. +- If emails don't arrive, check your spam folder and Resend dashboard logs. +- The template displays listing images via their public URLs; make sure images are reachable. diff --git a/package.json b/package.json index 7420bac..5f2e37f 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fredy", - "version": "19.5.1", + "version": "19.5.2", "description": "[F]ind [R]eal [E]states [d]amn eas[y].", "scripts": { "prepare": "husky", @@ -90,6 +90,7 @@ "react-range-slider-input": "^3.3.2", "react-router": "7.13.0", "react-router-dom": "7.13.0", + "resend": "^6.9.2", "restana": "5.1.0", "semver": "^7.7.4", "serve-static": "2.2.1", diff --git a/ui/src/views/jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx b/ui/src/views/jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx index fd19e16..5fe66de 100644 --- a/ui/src/views/jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx +++ b/ui/src/views/jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx @@ -197,27 +197,6 @@ export default function NotificationAdapterMutator({ } > - {validationMessage != null && ( - Error} - style={{ marginBottom: '1rem' }} - description={

} - /> - )} - {successMessage != null && ( - Yay!} - style={{ marginBottom: '1rem' }} - description={

} - /> - )} - {description != null ? (

{description}

) : ( @@ -264,6 +243,28 @@ export default function NotificationAdapterMutator({
{selectedAdapter.readme != null && }
+ + {validationMessage != null && ( + Error} + style={{ marginBottom: '1rem' }} + description={

} + /> + )} + {successMessage != null && ( + Yay!} + style={{ marginBottom: '1rem' }} + description={

} + /> + )} + {getFieldsFor(selectedAdapter)} )} diff --git a/yarn.lock b/yarn.lock index 346f036..9afc811 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1616,6 +1616,11 @@ "@sendgrid/client" "^8.1.5" "@sendgrid/helpers" "^8.0.0" +"@stablelib/base64@^1.0.0": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@stablelib/base64/-/base64-1.0.1.tgz#bdfc1c6d3a62d7a3b7bbc65b6cce1bb4561641be" + integrity sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ== + "@tiptap/core@^3.10.7": version "3.16.0" resolved "https://registry.npmjs.org/@tiptap/core/-/core-3.16.0.tgz" @@ -3608,6 +3613,11 @@ fast-levenshtein@^2.0.6: resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-sha256@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/fast-sha256/-/fast-sha256-1.3.0.tgz#7916ba2054eeb255982608cccd0f6660c79b7ae6" + integrity sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ== + fd-slicer@~1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz" @@ -5951,6 +5961,11 @@ possible-typed-array-names@^1.0.0: resolved "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz" integrity sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg== +postal-mime@2.7.3: + version "2.7.3" + resolved "https://registry.yarnpkg.com/postal-mime/-/postal-mime-2.7.3.tgz#358d92192656a262568ffc7a441a713131aa1272" + integrity sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw== + postcss@^8.5.6: version "8.5.6" resolved "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz" @@ -6621,6 +6636,14 @@ require-directory@^2.1.1: resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz" integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== +resend@^6.9.2: + version "6.9.2" + resolved "https://registry.yarnpkg.com/resend/-/resend-6.9.2.tgz#0aae7681060c535915ce6cca97740950bf33b75a" + integrity sha512-uIM6CQ08tS+hTCRuKBFbOBvHIGaEhqZe8s4FOgqsVXSbQLAhmNWpmUhG3UAtRnmcwTWFUqnHa/+Vux8YGPyDBA== + dependencies: + postal-mime "2.7.3" + svix "1.84.1" + resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz" @@ -7026,6 +7049,14 @@ split-on-first@^3.0.0: resolved "https://registry.npmjs.org/split-on-first/-/split-on-first-3.0.0.tgz" integrity sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA== +standardwebhooks@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/standardwebhooks/-/standardwebhooks-1.0.0.tgz#5faa23ceacbf9accd344361101d9e3033b64324f" + integrity sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg== + dependencies: + "@stablelib/base64" "^1.0.0" + fast-sha256 "^1.3.0" + statuses@2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz" @@ -7254,6 +7285,14 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +svix@1.84.1: + version "1.84.1" + resolved "https://registry.yarnpkg.com/svix/-/svix-1.84.1.tgz#9e086455acf01143fe0f90c5f618393c3e3591cf" + integrity sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ== + dependencies: + standardwebhooks "1.0.0" + uuid "^10.0.0" + tar-fs@^2.0.0: version "2.1.3" resolved "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz" @@ -7598,6 +7637,11 @@ utility-types@^3.10.0: resolved "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz" integrity sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw== +uuid@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" + integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== + vfile-message@^4.0.0: version "4.0.3" resolved "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz"