/* * Copyright (c) 2025 by Christian Kellner. * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause */ import mailjet from 'node-mailjet'; import path from 'path'; import fs from 'fs'; import Handlebars from 'handlebars'; import fetch from 'node-fetch'; import { markdown2Html } from '../../services/markdown.js'; import { getDirName, normalizeImageUrl } from '../../utils.js'; import logger from '../../services/logger.js'; const __dirname = getDirName(); const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8'); const emailTemplate = Handlebars.compile(template); const guessMime = (url) => { const lower = url.split('?')[0].toLowerCase(); if (lower.endsWith('.png')) return 'image/png'; if (lower.endsWith('.gif')) return 'image/gif'; return 'image/jpeg'; }; const toBase64 = async (url) => { try { const res = await fetch(url); if (!res.ok) throw new Error(`Fetch failed with status ${res.status} for URL: ${url}`); const ab = await res.arrayBuffer(); return Buffer.from(ab).toString('base64'); } catch (error) { logger.error(`Error fetching image from ${url}:`, error.message); throw error; } }; const mapListingsWithCid = async (serviceName, jobKey, listings) => { const out = []; const attachments = []; for (let i = 0; i < listings.length; i++) { const l = listings[i] || {}; const imgUrl = normalizeImageUrl(l.image); const item = { title: l.title || '', link: l.link || '', address: l.address || '', size: l.size || '', price: l.price || '', serviceName, jobKey, hasImage: false, imageCid: '', }; if (imgUrl) { try { const base64 = await toBase64(imgUrl); const cid = `listing-${i}`; attachments.push({ ContentType: guessMime(imgUrl), Filename: `listing-${i}.${imgUrl.split('.').pop().split('?')[0] || 'jpg'}`, Base64Content: base64, ContentID: cid, }); item.hasImage = true; item.imageCid = cid; } catch (error) { logger.warn(`Skipping image for listing ${i} due to error: ${error.message}`); } } out.push(item); } return { listings: out, attachments }; }; export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => { const { apiPublicKey, apiPrivateKey, receiver, from } = notificationConfig.find( (adapter) => adapter.id === config.id, ).fields; const to = receiver .trim() .split(',') .map((r) => ({ Email: r.trim() })) .filter((r) => r.Email.length > 0); const { listings, attachments } = await mapListingsWithCid(serviceName, jobKey, newListings); const html = emailTemplate({ serviceName: `Job: (${jobKey}) | Service: ${serviceName}`, numberOfListings: listings.length, listings, }); return mailjet .apiConnect(apiPublicKey, apiPrivateKey) .post('send', { version: 'v3.1' }) .request({ Messages: [ { From: { Email: from, Name: 'Fredy' }, To: to, Subject: `Fredy found ${listings.length} new listing(s) for ${serviceName}`, HTMLPart: html, InlinedAttachments: attachments, }, ], }); }; export const config = { id: 'mailjet', name: 'MailJet', description: 'MailJet is being used to send new listings via mail.', readme: markdown2Html('lib/notification/adapter/mailJet.md'), fields: { apiPublicKey: { type: 'text', label: 'Public Api Key', description: 'The public api key needed to access this service.', }, apiPrivateKey: { type: 'text', label: 'Private Api Key', description: 'The private api key needed to access this service.', }, receiver: { type: 'email', label: 'Receiver Email', description: 'The email address (single one) which Fredy is using to send notifications to.', }, from: { type: 'email', label: 'Sender email', description: 'The email address from which Fredy send email. Beware, this email address needs to be verified by Sendgrid.', }, }, };