From 9601e179bcfe509b35306bc804d2c9f43165b0dc Mon Sep 17 00:00:00 2001 From: Darren Clarke Date: Sat, 14 Feb 2026 21:37:50 +0100 Subject: [PATCH] Delta chat WIP --- apps/bridge-deltachat/Dockerfile | 49 +++ apps/bridge-deltachat/docker-entrypoint.sh | 5 + apps/bridge-deltachat/package.json | 28 ++ apps/bridge-deltachat/src/attachments.ts | 35 ++ apps/bridge-deltachat/src/index.ts | 42 ++ apps/bridge-deltachat/src/lib/logger.ts | 77 ++++ apps/bridge-deltachat/src/routes.ts | 111 +++++ apps/bridge-deltachat/src/service.ts | 381 ++++++++++++++++++ apps/bridge-deltachat/src/types.ts | 8 + apps/bridge-deltachat/tsconfig.json | 27 ++ docker/compose/bridge-deltachat.yml | 20 + docker/scripts/docker.js | 3 +- package.json | 2 + .../controllers/_channel/cdr_deltachat.coffee | 249 ++++++++++++ .../article_action/cdr_deltachat.coffee | 79 ++++ .../app/views/cdr_deltachat/form_add.jst.eco | 47 +++ .../app/views/cdr_deltachat/form_edit.jst.eco | 55 +++ .../app/views/cdr_deltachat/index.jst.eco | 49 +++ .../stylesheets/addons/cdr_deltachat.css | 4 + .../channels_cdr_deltachat_controller.rb | 290 +++++++++++++ .../article-type/plugins/deltachatMessage.ts | 7 + .../action/plugins/cdr_deltachat.ts | 69 ++++ .../app/jobs/communicate_cdr_deltachat_job.rb | 114 ++++++ .../models/channel/driver/cdr_deltachat.rb | 33 ++ .../enqueue_communicate_cdr_deltachat_job.rb | 31 ++ ...hannels_cdr_deltachat_controller_policy.rb | 7 + .../src/config/initializers/cdr_deltachat.rb | 17 + .../config/routes/channel_cdr_deltachat.rb | 15 + .../20260214000001_cdr_deltachat_channel.rb | 27 ++ .../src/lib/cdr_deltachat.rb | 75 ++++ .../src/lib/cdr_deltachat_api.rb | 77 ++++ .../assets/images/icons/cdr_deltachat.svg | 5 + 32 files changed, 2037 insertions(+), 1 deletion(-) create mode 100644 apps/bridge-deltachat/Dockerfile create mode 100755 apps/bridge-deltachat/docker-entrypoint.sh create mode 100644 apps/bridge-deltachat/package.json create mode 100644 apps/bridge-deltachat/src/attachments.ts create mode 100644 apps/bridge-deltachat/src/index.ts create mode 100644 apps/bridge-deltachat/src/lib/logger.ts create mode 100644 apps/bridge-deltachat/src/routes.ts create mode 100644 apps/bridge-deltachat/src/service.ts create mode 100644 apps/bridge-deltachat/src/types.ts create mode 100644 apps/bridge-deltachat/tsconfig.json create mode 100644 docker/compose/bridge-deltachat.yml create mode 100644 packages/zammad-addon-link/src/app/assets/javascripts/app/controllers/_channel/cdr_deltachat.coffee create mode 100644 packages/zammad-addon-link/src/app/assets/javascripts/app/controllers/ticket_zoom/article_action/cdr_deltachat.coffee create mode 100644 packages/zammad-addon-link/src/app/assets/javascripts/app/views/cdr_deltachat/form_add.jst.eco create mode 100644 packages/zammad-addon-link/src/app/assets/javascripts/app/views/cdr_deltachat/form_edit.jst.eco create mode 100644 packages/zammad-addon-link/src/app/assets/javascripts/app/views/cdr_deltachat/index.jst.eco create mode 100644 packages/zammad-addon-link/src/app/assets/stylesheets/addons/cdr_deltachat.css create mode 100644 packages/zammad-addon-link/src/app/controllers/channels_cdr_deltachat_controller.rb create mode 100644 packages/zammad-addon-link/src/app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/article-type/plugins/deltachatMessage.ts create mode 100644 packages/zammad-addon-link/src/app/frontend/shared/entities/ticket-article/action/plugins/cdr_deltachat.ts create mode 100644 packages/zammad-addon-link/src/app/jobs/communicate_cdr_deltachat_job.rb create mode 100644 packages/zammad-addon-link/src/app/models/channel/driver/cdr_deltachat.rb create mode 100644 packages/zammad-addon-link/src/app/models/ticket/article/enqueue_communicate_cdr_deltachat_job.rb create mode 100644 packages/zammad-addon-link/src/app/policies/controllers/channels_cdr_deltachat_controller_policy.rb create mode 100644 packages/zammad-addon-link/src/config/initializers/cdr_deltachat.rb create mode 100644 packages/zammad-addon-link/src/config/routes/channel_cdr_deltachat.rb create mode 100644 packages/zammad-addon-link/src/db/addon/link/20260214000001_cdr_deltachat_channel.rb create mode 100644 packages/zammad-addon-link/src/lib/cdr_deltachat.rb create mode 100644 packages/zammad-addon-link/src/lib/cdr_deltachat_api.rb create mode 100644 packages/zammad-addon-link/src/public/assets/images/icons/cdr_deltachat.svg diff --git a/apps/bridge-deltachat/Dockerfile b/apps/bridge-deltachat/Dockerfile new file mode 100644 index 0000000..0847e99 --- /dev/null +++ b/apps/bridge-deltachat/Dockerfile @@ -0,0 +1,49 @@ +FROM node:22-bookworm-slim AS base + +FROM base AS builder +ARG APP_DIR=/opt/bridge-deltachat +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN mkdir -p ${APP_DIR}/ +RUN corepack enable && corepack prepare pnpm@9.15.4 --activate +RUN pnpm add -g turbo +WORKDIR ${APP_DIR} +COPY . . +RUN turbo prune --scope=@link-stack/bridge-deltachat --docker + +FROM base AS installer +ARG APP_DIR=/opt/bridge-deltachat +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +WORKDIR ${APP_DIR} +RUN corepack enable && corepack prepare pnpm@9.15.4 --activate +COPY --from=builder ${APP_DIR}/out/json/ . +COPY --from=builder ${APP_DIR}/out/full/ . +COPY --from=builder ${APP_DIR}/out/pnpm-lock.yaml ./pnpm-lock.yaml +RUN pnpm install --frozen-lockfile +RUN pnpm add -g turbo +RUN turbo run build --filter=@link-stack/bridge-deltachat + +FROM base as runner +ARG BUILD_DATE +ARG VERSION +ARG APP_DIR=/opt/bridge-deltachat +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN mkdir -p ${APP_DIR}/ +RUN DEBIAN_FRONTEND=noninteractive apt-get update && \ + apt-get install -y --no-install-recommends \ + dumb-init +RUN corepack enable && corepack prepare pnpm@9.15.4 --activate +WORKDIR ${APP_DIR} +COPY --from=installer ${APP_DIR} ./ +RUN chown -R node:node ${APP_DIR} +WORKDIR ${APP_DIR}/apps/bridge-deltachat/ +RUN chmod +x docker-entrypoint.sh +USER node +RUN mkdir /home/node/deltachat-data +EXPOSE 5001 +ENV PORT 5001 +ENV NODE_ENV production +ENV COREPACK_ENABLE_NETWORK=0 +ENTRYPOINT ["/opt/bridge-deltachat/apps/bridge-deltachat/docker-entrypoint.sh"] diff --git a/apps/bridge-deltachat/docker-entrypoint.sh b/apps/bridge-deltachat/docker-entrypoint.sh new file mode 100755 index 0000000..13f5bd0 --- /dev/null +++ b/apps/bridge-deltachat/docker-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -e +echo "starting bridge-deltachat" +exec dumb-init pnpm run start diff --git a/apps/bridge-deltachat/package.json b/apps/bridge-deltachat/package.json new file mode 100644 index 0000000..3f76524 --- /dev/null +++ b/apps/bridge-deltachat/package.json @@ -0,0 +1,28 @@ +{ + "name": "@link-stack/bridge-deltachat", + "version": "3.5.0-beta.1", + "main": "build/main/index.js", + "author": "Darren Clarke ", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@deltachat/jsonrpc-client": "^1.151.1", + "@deltachat/stdio-rpc-server": "^1.151.1", + "@hapi/hapi": "^21.4.3", + "@hapipal/schmervice": "^3.0.0", + "@hapipal/toys": "^4.0.0", + "hapi-pino": "^13.0.0", + "pino": "^9.6.0", + "pino-pretty": "^13.0.0" + }, + "devDependencies": { + "@types/node": "*", + "dotenv-cli": "^10.0.0", + "tsx": "^4.20.6", + "typescript": "^5.9.3" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "dotenv -- tsx src/index.ts", + "start": "node build/main/index.js" + } +} diff --git a/apps/bridge-deltachat/src/attachments.ts b/apps/bridge-deltachat/src/attachments.ts new file mode 100644 index 0000000..abe4bde --- /dev/null +++ b/apps/bridge-deltachat/src/attachments.ts @@ -0,0 +1,35 @@ +/** + * Attachment size configuration for messaging channels + * + * Environment variables: + * - BRIDGE_MAX_ATTACHMENT_SIZE_MB: Maximum size for a single attachment in MB (default: 50) + */ + +/** + * Get the maximum attachment size in bytes from environment variable + * Defaults to 50MB if not set + */ +export function getMaxAttachmentSize(): number { + const envValue = process.env.BRIDGE_MAX_ATTACHMENT_SIZE_MB; + const sizeInMB = envValue ? parseInt(envValue, 10) : 50; + + if (isNaN(sizeInMB) || sizeInMB <= 0) { + console.warn(`Invalid BRIDGE_MAX_ATTACHMENT_SIZE_MB value: ${envValue}, using default 50MB`); + return 50 * 1024 * 1024; + } + + return sizeInMB * 1024 * 1024; +} + +/** + * Get the maximum total size for all attachments in a message + * This is 4x the single attachment size + */ +export function getMaxTotalAttachmentSize(): number { + return getMaxAttachmentSize() * 4; +} + +/** + * Maximum number of attachments per message + */ +export const MAX_ATTACHMENTS = 10; diff --git a/apps/bridge-deltachat/src/index.ts b/apps/bridge-deltachat/src/index.ts new file mode 100644 index 0000000..78a4627 --- /dev/null +++ b/apps/bridge-deltachat/src/index.ts @@ -0,0 +1,42 @@ +import * as Hapi from "@hapi/hapi"; +import hapiPino from "hapi-pino"; +import Schmervice from "@hapipal/schmervice"; +import DeltaChatService from "./service.ts"; +import { + ConfigureBotRoute, + GetBotRoute, + SendMessageRoute, + UnconfigureBotRoute, + HealthRoute, +} from "./routes.ts"; +import { createLogger } from "./lib/logger"; + +const logger = createLogger("bridge-deltachat-index"); + +const server = Hapi.server({ port: 5001 }); + +const startServer = async () => { + await server.register({ plugin: hapiPino }); + + server.route(ConfigureBotRoute); + server.route(GetBotRoute); + server.route(SendMessageRoute); + server.route(UnconfigureBotRoute); + server.route(HealthRoute); + + await server.register(Schmervice); + server.registerService(DeltaChatService); + + await server.start(); + + return server; +}; + +const main = async () => { + await startServer(); +}; + +main().catch((err) => { + logger.error(err); + process.exit(1); +}); diff --git a/apps/bridge-deltachat/src/lib/logger.ts b/apps/bridge-deltachat/src/lib/logger.ts new file mode 100644 index 0000000..c72a3c9 --- /dev/null +++ b/apps/bridge-deltachat/src/lib/logger.ts @@ -0,0 +1,77 @@ +import pino, { Logger as PinoLogger, LoggerOptions } from 'pino'; + +export type Logger = PinoLogger; + +const getLogLevel = (): string => { + return process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'); +}; + +const getPinoConfig = (): LoggerOptions => { + const isDevelopment = process.env.NODE_ENV !== 'production'; + + const baseConfig: LoggerOptions = { + level: getLogLevel(), + formatters: { + level: (label) => { + return { level: label.toUpperCase() }; + }, + }, + timestamp: () => `,"timestamp":"${new Date(Date.now()).toISOString()}"`, + redact: { + paths: [ + 'password', + 'token', + 'secret', + 'api_key', + 'apiKey', + 'authorization', + 'cookie', + 'access_token', + 'refresh_token', + '*.password', + '*.token', + '*.secret', + '*.api_key', + '*.apiKey', + '*.authorization', + '*.cookie', + '*.access_token', + '*.refresh_token', + 'headers.authorization', + 'headers.cookie', + 'headers.Authorization', + 'headers.Cookie', + 'credentials.password', + 'credentials.secret', + 'credentials.token', + ], + censor: '[REDACTED]', + }, + }; + + if (isDevelopment) { + return { + ...baseConfig, + transport: { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'SYS:standard', + ignore: 'pid,hostname', + singleLine: false, + messageFormat: '{msg}', + }, + }, + }; + } + + return baseConfig; +}; + +export const logger: Logger = pino(getPinoConfig()); + +export const createLogger = (name: string, context?: Record): Logger => { + return logger.child({ name, ...context }); +}; + +export default logger; diff --git a/apps/bridge-deltachat/src/routes.ts b/apps/bridge-deltachat/src/routes.ts new file mode 100644 index 0000000..696e171 --- /dev/null +++ b/apps/bridge-deltachat/src/routes.ts @@ -0,0 +1,111 @@ +import * as Hapi from "@hapi/hapi"; +import Toys from "@hapipal/toys"; +import DeltaChatService from "./service.ts"; + +const withDefaults = Toys.withRouteDefaults({ + options: { + cors: true, + }, +}); + +const getService = (request: Hapi.Request): DeltaChatService => { + const { deltaChatService } = request.services(); + + return deltaChatService as DeltaChatService; +}; + +interface ConfigureRequest { + email: string; + password: string; +} + +interface SendMessageRequest { + email: string; + message: string; + attachments?: Array<{ data: string; filename: string; mime_type: string }>; +} + +export const ConfigureBotRoute = withDefaults({ + method: "post", + path: "/api/bots/{id}/configure", + options: { + description: "Configure a bot with email credentials", + async handler(request: Hapi.Request, h: Hapi.ResponseToolkit) { + const { id } = request.params; + const { email, password } = request.payload as ConfigureRequest; + const service = getService(request); + + try { + const result = await service.configure(id, email, password); + request.logger.info({ id, email }, "Bot configured at %s", new Date().toISOString()); + return h.response(result).code(200); + } catch (err: any) { + request.logger.error({ id, error: err.message }, "Failed to configure bot"); + return h.response({ error: err.message }).code(500); + } + }, + }, +}); + +export const GetBotRoute = withDefaults({ + method: "get", + path: "/api/bots/{id}", + options: { + description: "Get bot status", + async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) { + const { id } = request.params; + const service = getService(request); + return service.getBot(id); + }, + }, +}); + +export const SendMessageRoute = withDefaults({ + method: "post", + path: "/api/bots/{id}/send", + options: { + description: "Send a message", + async handler(request: Hapi.Request, h: Hapi.ResponseToolkit) { + const { id } = request.params; + const { email, message, attachments } = request.payload as SendMessageRequest; + const service = getService(request); + + const result = await service.send(id, email, message, attachments); + request.logger.info( + { id, attachmentCount: attachments?.length || 0 }, + "Sent a message at %s", + new Date().toISOString(), + ); + + return h.response({ result }).code(200); + }, + }, +}); + +export const UnconfigureBotRoute = withDefaults({ + method: "post", + path: "/api/bots/{id}/unconfigure", + options: { + description: "Unconfigure and remove a bot", + async handler(request: Hapi.Request, h: Hapi.ResponseToolkit) { + const { id } = request.params; + const service = getService(request); + + await service.unconfigure(id); + request.logger.info({ id }, "Bot unconfigured at %s", new Date().toISOString()); + + return h.response().code(200); + }, + }, +}); + +export const HealthRoute = withDefaults({ + method: "get", + path: "/api/health", + options: { + description: "Health check", + async handler(_request: Hapi.Request, h: Hapi.ResponseToolkit) { + return h.response({ status: "ok" }).code(200); + }, + }, +}); diff --git a/apps/bridge-deltachat/src/service.ts b/apps/bridge-deltachat/src/service.ts new file mode 100644 index 0000000..c2b214b --- /dev/null +++ b/apps/bridge-deltachat/src/service.ts @@ -0,0 +1,381 @@ +import { Server } from "@hapi/hapi"; +import { Service } from "@hapipal/schmervice"; +import { startDeltaChat, DeltaChat } from "@deltachat/stdio-rpc-server"; +import fs from "fs"; +import path from "path"; +import os from "os"; +import { createLogger } from "./lib/logger"; +import { + getMaxAttachmentSize, + getMaxTotalAttachmentSize, + MAX_ATTACHMENTS, +} from "./attachments"; + +const logger = createLogger("bridge-deltachat-service"); + +interface BotMapping { + [botId: string]: number; +} + +export default class DeltaChatService extends Service { + private dc: DeltaChat | null = null; + private botMapping: BotMapping = {}; + private dataDir: string; + private mappingFile: string; + + constructor(server: Server, options: never) { + super(server, options); + this.dataDir = process.env.DELTACHAT_DATA_DIR || "/home/node/deltachat-data"; + this.mappingFile = path.join(this.dataDir, "bot-mapping.json"); + } + + async initialize(): Promise { + if (!fs.existsSync(this.dataDir)) { + fs.mkdirSync(this.dataDir, { recursive: true }); + } + + logger.info({ dataDir: this.dataDir }, "Starting deltachat-rpc-server"); + this.dc = await startDeltaChat(this.dataDir); + logger.info("deltachat-rpc-server started"); + + this.loadBotMapping(); + + for (const [botId, accountId] of Object.entries(this.botMapping)) { + try { + const isConfigured = await this.dc.rpc.isConfigured(accountId); + if (isConfigured) { + await this.dc.rpc.startIo(accountId); + logger.info({ botId, accountId }, "Resumed IO for existing bot"); + } else { + logger.warn({ botId, accountId }, "Account not configured, removing from mapping"); + delete this.botMapping[botId]; + } + } catch (err) { + logger.error({ botId, accountId, err }, "Failed to resume bot, removing from mapping"); + delete this.botMapping[botId]; + } + } + + this.saveBotMapping(); + this.registerEventListeners(); + } + + async teardown(): Promise { + if (this.dc) { + for (const [botId, accountId] of Object.entries(this.botMapping)) { + try { + await this.dc.rpc.stopIo(accountId); + logger.info({ botId, accountId }, "Stopped IO for bot"); + } catch (err) { + logger.error({ botId, accountId, err }, "Error stopping IO"); + } + } + this.dc.close(); + this.dc = null; + } + } + + private loadBotMapping(): void { + if (fs.existsSync(this.mappingFile)) { + try { + const data = fs.readFileSync(this.mappingFile, "utf-8"); + this.botMapping = JSON.parse(data); + logger.info({ botCount: Object.keys(this.botMapping).length }, "Loaded bot mapping"); + } catch (err) { + logger.error({ err }, "Failed to load bot mapping, starting fresh"); + this.botMapping = {}; + } + } + } + + private saveBotMapping(): void { + fs.writeFileSync(this.mappingFile, JSON.stringify(this.botMapping, null, 2), "utf-8"); + } + + private validateBotId(id: string): void { + if (!/^[a-zA-Z0-9_-]+$/.test(id)) { + throw new Error(`Invalid bot ID format: ${id}`); + } + } + + private getBotIdForAccount(accountId: number): string | undefined { + return Object.entries(this.botMapping).find(([, aid]) => aid === accountId)?.[0]; + } + + private registerEventListeners(): void { + if (!this.dc) return; + + const dc = this.dc; + + (async () => { + for await (const event of dc.events) { + try { + if (event.kind === "IncomingMsg") { + const { accountId, chatId, msgId } = event; + await this.handleIncomingMessage(accountId, chatId, msgId); + } + } catch (err) { + logger.error({ err, event: event.kind }, "Error handling event"); + } + } + })().catch((err) => { + logger.error({ err }, "Event listener loop exited"); + }); + } + + private async handleIncomingMessage( + accountId: number, + chatId: number, + msgId: number, + ): Promise { + if (!this.dc) return; + + const botId = this.getBotIdForAccount(accountId); + if (!botId) { + logger.warn({ accountId }, "Received message for unknown account"); + return; + } + + const msg = await this.dc.rpc.getMessage(accountId, msgId); + + // Skip bot messages and non-chat messages (plain email) + if (msg.isBot || !msg.isIncoming) { + logger.debug({ msgId, isBot: msg.isBot, isIncoming: msg.isIncoming }, "Skipping message"); + return; + } + + const contact = await this.dc.rpc.getContact(accountId, msg.fromId); + const senderEmail = contact.address; + const botConfig = await this.dc.rpc.getConfig(accountId, "configured_addr"); + const botEmail = botConfig || ""; + + logger.info({ botId, senderEmail, msgId }, "Processing incoming message"); + + let attachment: string | undefined; + let filename: string | undefined; + let mimeType: string | undefined; + + if (msg.file) { + try { + const fileData = fs.readFileSync(msg.file); + attachment = fileData.toString("base64"); + filename = msg.fileName || path.basename(msg.file); + mimeType = msg.fileMime || "application/octet-stream"; + logger.info({ filename, mimeType, size: fileData.length }, "Attachment found"); + } catch (err) { + logger.error({ err, file: msg.file }, "Failed to read attachment file"); + } + } + + const payload: Record = { + from: senderEmail, + to: botEmail, + message: msg.text || "", + message_id: String(msgId), + sent_at: new Date(msg.timestamp * 1000).toISOString(), + }; + + if (attachment) { + payload.attachment = attachment; + payload.filename = filename; + payload.mime_type = mimeType; + } + + const zammadUrl = process.env.ZAMMAD_URL || "http://zammad-nginx:8080"; + try { + const response = await fetch( + `${zammadUrl}/api/v1/channels_cdr_deltachat_bot_webhook/${botId}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + logger.error({ status: response.status, error: errorText, botId }, "Failed to send message to Zammad"); + } else { + logger.info({ botId, msgId }, "Message forwarded to Zammad"); + } + } catch (err) { + logger.error({ err, botId }, "Failed to POST to Zammad webhook"); + } + + try { + await this.dc.rpc.markseenMsgs(accountId, [msgId]); + } catch (err) { + logger.error({ err, msgId }, "Failed to mark message as seen"); + } + } + + async configure( + botId: string, + email: string, + password: string, + ): Promise<{ accountId: number; email: string }> { + this.validateBotId(botId); + if (!this.dc) throw new Error("DeltaChat not initialized"); + + if (this.botMapping[botId] !== undefined) { + throw new Error(`Bot ${botId} is already configured`); + } + + const accountId = await this.dc.rpc.addAccount(); + logger.info({ botId, accountId, email }, "Created new account"); + + try { + await this.dc.rpc.batchSetConfig(accountId, { + addr: email, + mail_pw: password, + bot: "1", + e2ee_enabled: "1", + }); + + logger.info({ botId, accountId }, "Configuring account (verifying credentials)..."); + await this.dc.rpc.configure(accountId); + logger.info({ botId, accountId }, "Account configured successfully"); + + await this.dc.rpc.startIo(accountId); + logger.info({ botId, accountId }, "IO started"); + + this.botMapping[botId] = accountId; + this.saveBotMapping(); + + return { accountId, email }; + } catch (err) { + logger.error({ botId, accountId, err }, "Configuration failed, removing account"); + try { + await this.dc.rpc.removeAccount(accountId); + } catch (removeErr) { + logger.error({ removeErr }, "Failed to clean up account after configuration failure"); + } + throw err; + } + } + + async getBot(botId: string): Promise<{ configured: boolean; email: string | null }> { + this.validateBotId(botId); + + const accountId = this.botMapping[botId]; + if (accountId === undefined || !this.dc) { + return { configured: false, email: null }; + } + + try { + const isConfigured = await this.dc.rpc.isConfigured(accountId); + const email = await this.dc.rpc.getConfig(accountId, "configured_addr"); + return { configured: isConfigured, email: email || null }; + } catch { + return { configured: false, email: null }; + } + } + + async unconfigure(botId: string): Promise { + this.validateBotId(botId); + if (!this.dc) throw new Error("DeltaChat not initialized"); + + const accountId = this.botMapping[botId]; + if (accountId === undefined) { + logger.warn({ botId }, "Bot not found for unconfigure"); + return; + } + + try { + await this.dc.rpc.stopIo(accountId); + } catch (err) { + logger.warn({ botId, accountId, err }, "Error stopping IO during unconfigure"); + } + + try { + await this.dc.rpc.removeAccount(accountId); + } catch (err) { + logger.warn({ botId, accountId, err }, "Error removing account during unconfigure"); + } + + delete this.botMapping[botId]; + this.saveBotMapping(); + logger.info({ botId, accountId }, "Bot unconfigured and removed"); + } + + async send( + botId: string, + email: string, + message: string, + attachments?: Array<{ data: string; filename: string; mime_type: string }>, + ): Promise<{ recipient: string; timestamp: string; source: string }> { + this.validateBotId(botId); + if (!this.dc) throw new Error("DeltaChat not initialized"); + + const accountId = this.botMapping[botId]; + if (accountId === undefined) { + throw new Error(`Bot ${botId} is not configured`); + } + + const contactId = await this.dc.rpc.createContact(accountId, email, ""); + const chatId = await this.dc.rpc.createChatByContactId(accountId, contactId); + + if (attachments && attachments.length > 0) { + const MAX_ATTACHMENT_SIZE = getMaxAttachmentSize(); + const MAX_TOTAL_SIZE = getMaxTotalAttachmentSize(); + + if (attachments.length > MAX_ATTACHMENTS) { + throw new Error(`Too many attachments: ${attachments.length} (max ${MAX_ATTACHMENTS})`); + } + + let totalSize = 0; + + for (const att of attachments) { + const estimatedSize = (att.data.length * 3) / 4; + + if (estimatedSize > MAX_ATTACHMENT_SIZE) { + logger.warn( + { filename: att.filename, size: estimatedSize, maxSize: MAX_ATTACHMENT_SIZE }, + "Attachment exceeds size limit, skipping", + ); + continue; + } + + totalSize += estimatedSize; + if (totalSize > MAX_TOTAL_SIZE) { + logger.warn({ totalSize, maxTotalSize: MAX_TOTAL_SIZE }, "Total attachment size exceeds limit, skipping remaining"); + break; + } + + const buffer = Buffer.from(att.data, "base64"); + const tmpFile = path.join(os.tmpdir(), `dc-${Date.now()}-${att.filename}`); + fs.writeFileSync(tmpFile, buffer); + + try { + await this.dc.rpc.sendMsg(accountId, chatId, { + text: message, + file: tmpFile, + }); + // Only include text with the first attachment; clear for subsequent + message = ""; + } finally { + try { + fs.unlinkSync(tmpFile); + } catch { + // ignore cleanup errors + } + } + } + + // If we had message text but all attachments were skipped, send text only + if (message) { + await this.dc.rpc.miscSendTextMessage(accountId, chatId, message); + } + } else { + await this.dc.rpc.miscSendTextMessage(accountId, chatId, message); + } + + const botEmail = (await this.dc.rpc.getConfig(accountId, "configured_addr")) || botId; + + return { + recipient: email, + timestamp: new Date().toISOString(), + source: botEmail, + }; + } +} diff --git a/apps/bridge-deltachat/src/types.ts b/apps/bridge-deltachat/src/types.ts new file mode 100644 index 0000000..ccf27c9 --- /dev/null +++ b/apps/bridge-deltachat/src/types.ts @@ -0,0 +1,8 @@ +import type DeltaChatService from "./service.ts"; + +declare module "@hapipal/schmervice" { + interface SchmerviceDecorator { + (namespace: "deltachat"): DeltaChatService; + } + type ServiceFunctionalInterface = { name: string }; +} diff --git a/apps/bridge-deltachat/tsconfig.json b/apps/bridge-deltachat/tsconfig.json new file mode 100644 index 0000000..7c4a507 --- /dev/null +++ b/apps/bridge-deltachat/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "build/main", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "inlineSources": true, + "skipLibCheck": true, + "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "incremental": true, + "composite": true, + "rewriteRelativeImportExtensions": true, + "types": ["node"], + "lib": ["es2022", "DOM"] + }, + "include": ["src/**/*.ts", "src/**/.*.ts"], + "exclude": ["node_modules/**"] +} diff --git a/docker/compose/bridge-deltachat.yml b/docker/compose/bridge-deltachat.yml new file mode 100644 index 0000000..5555092 --- /dev/null +++ b/docker/compose/bridge-deltachat.yml @@ -0,0 +1,20 @@ +services: + bridge-deltachat: + container_name: bridge-deltachat + build: + context: ../../ + dockerfile: ./apps/bridge-deltachat/Dockerfile + image: registry.gitlab.com/digiresilience/link/link-stack/bridge-deltachat:${LINK_STACK_VERSION} + restart: ${RESTART} + environment: + PORT: 5001 + NODE_ENV: production + ZAMMAD_URL: http://zammad-nginx:8080 + volumes: + - bridge-deltachat-data:/home/node/deltachat-data + ports: + - 5001:5001 + +volumes: + bridge-deltachat-data: + driver: local diff --git a/docker/scripts/docker.js b/docker/scripts/docker.js index 7fa18a2..c9883d5 100644 --- a/docker/scripts/docker.js +++ b/docker/scripts/docker.js @@ -5,11 +5,12 @@ const app = process.argv[2]; const command = process.argv[3]; const files = { - all: ["zammad", "postgresql", "opensearch", "signal-cli-rest-api", "bridge-whatsapp"], + all: ["zammad", "postgresql", "opensearch", "signal-cli-rest-api", "bridge-whatsapp", "bridge-deltachat"], dev: ["zammad", "postgresql", "opensearch", "signal-cli-rest-api"], opensearch: ["opensearch"], zammad: ["zammad", "postgresql", "opensearch", "signal-cli-rest-api"], whatsapp: ["bridge-whatsapp"], + deltachat: ["bridge-deltachat"], }; diff --git a/package.json b/package.json index 9bc5dad..b2f44ae 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "docker:zammad:build": "node docker/scripts/docker.js zammad build", "docker:whatsapp:up": "node docker/scripts/docker.js whatsapp up", "docker:whatsapp:down": "node docker/scripts/docker.js whatsapp down", + "docker:deltachat:up": "node docker/scripts/docker.js deltachat up", + "docker:deltachat:down": "node docker/scripts/docker.js deltachat down", "docker:zammad:restart": "docker restart zammad-railsserver zammad-scheduler" }, "repository": { diff --git a/packages/zammad-addon-link/src/app/assets/javascripts/app/controllers/_channel/cdr_deltachat.coffee b/packages/zammad-addon-link/src/app/assets/javascripts/app/controllers/_channel/cdr_deltachat.coffee new file mode 100644 index 0000000..4695454 --- /dev/null +++ b/packages/zammad-addon-link/src/app/assets/javascripts/app/controllers/_channel/cdr_deltachat.coffee @@ -0,0 +1,249 @@ +class ChannelCdrDeltachat extends App.ControllerSubContent + requiredPermission: 'admin.channel_cdr_deltachat' + events: + 'click .js-new': 'new' + 'click .js-edit': 'edit' + 'click .js-delete': 'delete' + 'click .js-disable': 'disable' + 'click .js-enable': 'enable' + 'click .js-rotate-token': 'rotateToken' + + constructor: -> + super + + #@interval(@load, 60000) + @load() + + load: => + @startLoading() + @ajax( + id: 'cdr_deltachat_index' + type: 'GET' + url: "#{@apiPath}/channels_cdr_deltachat" + processData: true + success: (data) => + @stopLoading() + App.Collection.loadAssets(data.assets) + @render(data) + ) + + render: (data) => + + channels = [] + for channel_id in data.channel_ids + channel = App.Channel.find(channel_id) + if channel && channel.options + displayName = '-' + if channel.group_id + group = App.Group.find(channel.group_id) + displayName = group.displayName() + channel.options.groupName = displayName + channels.push channel + @html App.view('cdr_deltachat/index')( + channels: channels + ) + + new: (e) => + e.preventDefault() + new FormAdd( + container: @el.parents('.content') + load: @load + ) + + edit: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + channel = App.Channel.find(id) + new FormEdit( + channel: channel + container: @el.parents('.content') + load: @load + ) + + delete: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + new App.ControllerConfirm( + message: 'Sure?' + callback: => + @ajax( + id: 'cdr_deltachat_delete' + type: 'DELETE' + url: "#{@apiPath}/channels_cdr_deltachat" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + container: @el.closest('.content') + ) + + rotateToken: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + + new App.ControllerConfirm( + message: 'This will break the submission form!' + buttonSubmit: 'Reset token' + head: 'Reset the submission token?' + callback: => + @ajax( + id: 'cdr_deltachat_disable' + type: 'POST' + url: "#{@apiPath}/channels_cdr_deltachat_rotate_token" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + container: @el.closest('.content') + ) + disable: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + @ajax( + id: 'cdr_deltachat_disable' + type: 'POST' + url: "#{@apiPath}/channels_cdr_deltachat_disable" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + + enable: (e) => + e.preventDefault() + id = $(e.target).closest('.action').data('id') + @ajax( + id: 'cdr_deltachat_enable' + type: 'POST' + url: "#{@apiPath}/channels_cdr_deltachat_enable" + data: JSON.stringify(id: id) + processData: true + success: => + @load() + ) + +class FormAdd extends App.ControllerModal + head: 'Add DeltaChat Bot' + shown: true + button: 'Add' + buttonCancel: true + small: true + + content: -> + content = $(App.view('cdr_deltachat/form_add')()) + createOrgSelection = (selected_id) -> + return App.UiElement.select.render( + name: 'organization_id' + multiple: false + limit: 100 + null: false + relation: 'Organization' + nulloption: true + value: selected_id + class: 'form-control--small' + ) + createGroupSelection = (selected_id) -> + return App.UiElement.select.render( + name: 'group_id' + multiple: false + limit: 100 + null: false + relation: 'Group' + nulloption: true + value: selected_id + class: 'form-control--small' + ) + + content.find('.js-select').on('click', (e) => + @selectAll(e) + ) + content.find('.js-messagesGroup').replaceWith createGroupSelection(1) + content.find('.js-organization').replaceWith createOrgSelection(null) + content + + onClosed: => + return if !@isChanged + @isChanged = false + @load() + + onSubmit: (e) => + @formDisable(e) + @ajax( + id: 'cdr_deltachat_app_verify' + type: 'POST' + url: "#{@apiPath}/channels_cdr_deltachat" + data: JSON.stringify(@formParams()) + processData: true + success: => + @isChanged = true + @close() + error: (xhr) => + data = JSON.parse(xhr.responseText) + @formEnable(e) + error_message = App.i18n.translateContent(data.error || 'Unable to save DeltaChat bot.') + @el.find('.alert').removeClass('hidden').text(error_message) + ) + +class FormEdit extends App.ControllerModal + head: 'DeltaChat Bot Info' + shown: true + buttonCancel: true + + content: -> + content = $(App.view('cdr_deltachat/form_edit')(channel: @channel)) + + createOrgSelection = (selected_id) -> + return App.UiElement.select.render( + name: 'organization_id' + multiple: false + limit: 100 + null: false + relation: 'Organization' + nulloption: true + value: selected_id + class: 'form-control--small' + ) + createGroupSelection = (selected_id) -> + return App.UiElement.select.render( + name: 'group_id' + multiple: false + limit: 100 + null: false + relation: 'Group' + nulloption: true + value: selected_id + class: 'form-control--small' + ) + + content.find('.js-messagesGroup').replaceWith createGroupSelection(@channel.group_id) + content.find('.js-organization').replaceWith createOrgSelection(@channel.options.organization_id) + content + + onClosed: => + return if !@isChanged + @isChanged = false + @load() + + onSubmit: (e) => + @formDisable(e) + params = @formParams() + @channel.options = params + @ajax( + id: 'channel_cdr_deltachat_update' + type: 'PUT' + url: "#{@apiPath}/channels_cdr_deltachat/#{@channel.id}" + data: JSON.stringify(@formParams()) + processData: true + success: => + @isChanged = true + @close() + error: (xhr) => + data = JSON.parse(xhr.responseText) + @formEnable(e) + error_message = App.i18n.translateContent(data.error || 'Unable to save changes.') + @el.find('.alert').removeClass('hidden').text(error_message) + ) + +App.Config.set('cdr_deltachat', { prio: 5200, name: 'DeltaChat', parent: '#channels', target: '#channels/cdr_deltachat', controller: ChannelCdrDeltachat, permission: ['admin.channel_cdr_deltachat'] }, 'NavBarAdmin') diff --git a/packages/zammad-addon-link/src/app/assets/javascripts/app/controllers/ticket_zoom/article_action/cdr_deltachat.coffee b/packages/zammad-addon-link/src/app/assets/javascripts/app/controllers/ticket_zoom/article_action/cdr_deltachat.coffee new file mode 100644 index 0000000..748f434 --- /dev/null +++ b/packages/zammad-addon-link/src/app/assets/javascripts/app/controllers/ticket_zoom/article_action/cdr_deltachat.coffee @@ -0,0 +1,79 @@ +class CdrDeltachatReply + @action: (actions, ticket, article, ui) -> + return actions if ui.permissionCheck('ticket.customer') + + if article.sender.name is 'Customer' && article.type.name is 'cdr_deltachat' + actions.push { + name: 'reply' + type: 'cdrDeltachatMessageReply' + icon: 'reply' + href: '#' + } + + actions + + @perform: (articleContainer, type, ticket, article, ui) -> + return true if type isnt 'cdrDeltachatMessageReply' + + ui.scrollToCompose() + + # get reference article + type = App.TicketArticleType.find(article.type_id) + + articleNew = { + to: '' + cc: '' + body: '' + in_reply_to: '' + } + + if article.message_id + articleNew.in_reply_to = article.message_id + + # get current body + articleNew.body = ui.el.closest('.ticketZoom').find('.article-add [data-name="body"]').html().trim() || '' + + App.Event.trigger('ui::ticket::setArticleType', { + ticket: ticket + type: type + article: articleNew + position: 'end' + }) + + true + + @articleTypes: (articleTypes, ticket, ui) -> + return articleTypes if !ui.permissionCheck('ticket.agent') + + return articleTypes if !ticket || !ticket.create_article_type_id + + articleTypeCreate = App.TicketArticleType.find(ticket.create_article_type_id).name + + return articleTypes if articleTypeCreate isnt 'cdr_deltachat' + articleTypes.push { + name: 'cdr_deltachat' + icon: 'cdr-deltachat' + attributes: [] + internal: false, + features: ['attachment'] + maxTextLength: 10000 + warningTextLength: 5000 + } + articleTypes + + @setArticleTypePost: (type, ticket, ui) -> + return if type isnt 'cdr_deltachat' + rawHTML = ui.$('[data-name=body]').html() + cleanHTML = App.Utils.htmlRemoveRichtext(rawHTML) + if cleanHTML && cleanHTML.html() != rawHTML + ui.$('[data-name=body]').html(cleanHTML) + + @params: (type, params, ui) -> + if type is 'cdr_deltachat' + App.Utils.htmlRemoveRichtext(ui.$('[data-name=body]'), false) + params.content_type = 'text/plain' + params.body = App.Utils.html2text(params.body, true) + + params + +App.Config.set('310-CdrDeltachatReply', CdrDeltachatReply, 'TicketZoomArticleAction') diff --git a/packages/zammad-addon-link/src/app/assets/javascripts/app/views/cdr_deltachat/form_add.jst.eco b/packages/zammad-addon-link/src/app/assets/javascripts/app/views/cdr_deltachat/form_add.jst.eco new file mode 100644 index 0000000..cda231e --- /dev/null +++ b/packages/zammad-addon-link/src/app/assets/javascripts/app/views/cdr_deltachat/form_add.jst.eco @@ -0,0 +1,47 @@ + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+
+ +
+
+
+
+
+
diff --git a/packages/zammad-addon-link/src/app/assets/javascripts/app/views/cdr_deltachat/form_edit.jst.eco b/packages/zammad-addon-link/src/app/assets/javascripts/app/views/cdr_deltachat/form_edit.jst.eco new file mode 100644 index 0000000..e253b21 --- /dev/null +++ b/packages/zammad-addon-link/src/app/assets/javascripts/app/views/cdr_deltachat/form_edit.jst.eco @@ -0,0 +1,55 @@ + + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+ " class="form-control input js-select" readonly> +
+
+
diff --git a/packages/zammad-addon-link/src/app/assets/javascripts/app/views/cdr_deltachat/index.jst.eco b/packages/zammad-addon-link/src/app/assets/javascripts/app/views/cdr_deltachat/index.jst.eco new file mode 100644 index 0000000..f22c17d --- /dev/null +++ b/packages/zammad-addon-link/src/app/assets/javascripts/app/views/cdr_deltachat/index.jst.eco @@ -0,0 +1,49 @@ + + +
+ +<% if _.isEmpty(@channels): %> +
+

<%- @T('You have no configured %s right now.', 'DeltaChat bots') %>

+
+<% else: %> + +<% for channel in @channels: %> +
+
+

<%- @Icon('status', 'supergood-color inline') %> <%= channel.options.email_address %>

+
+
+
+

<%- @T('Group') %>

+ <% if channel.options: %> + <%= channel.options.groupName %> + <% end %> +
+ +
+

<%- @T('Endpoint URL') %>

+ <%- @T('Click the edit button to view the endpoint details ') %> +
+
+
+
<%- @T('Delete') %>
+
<%- @T('Reset Token') %>
+ <% if channel.active is true: %> +
<%- @T('Disable') %>
+ <% else: %> +
<%- @T('Enable') %>
+ <% end %> +
<%- @T('Edit') %>
+
+
+<% end %> +
diff --git a/packages/zammad-addon-link/src/app/assets/stylesheets/addons/cdr_deltachat.css b/packages/zammad-addon-link/src/app/assets/stylesheets/addons/cdr_deltachat.css new file mode 100644 index 0000000..bf5e7b2 --- /dev/null +++ b/packages/zammad-addon-link/src/app/assets/stylesheets/addons/cdr_deltachat.css @@ -0,0 +1,4 @@ +.icon-cdr-deltachat { + width: 17px; + height: 17px; +} diff --git a/packages/zammad-addon-link/src/app/controllers/channels_cdr_deltachat_controller.rb b/packages/zammad-addon-link/src/app/controllers/channels_cdr_deltachat_controller.rb new file mode 100644 index 0000000..6e5c414 --- /dev/null +++ b/packages/zammad-addon-link/src/app/controllers/channels_cdr_deltachat_controller.rb @@ -0,0 +1,290 @@ +# frozen_string_literal: true + +class ChannelsCdrDeltachatController < ApplicationController + prepend_before_action -> { authentication_check && authorize! }, except: [:webhook, :bot_webhook] + skip_before_action :verify_csrf_token, only: [:webhook, :bot_webhook] + + include CreatesTicketArticles + + def index + assets = {} + channel_ids = [] + Channel.where(area: 'DeltaChat::Account').order(:id).each do |channel| + assets = channel.assets(assets) + channel_ids.push channel.id + end + render json: { + assets: assets, + channel_ids: channel_ids + } + end + + def add + begin + errors = {} + errors['group_id'] = 'required' if params[:group_id].blank? + + if errors.present? + render json: { + errors: errors + }, status: :bad_request + return + end + channel = Channel.create( + area: 'DeltaChat::Account', + options: { + adapter: 'cdr_deltachat', + email_address: params[:email_address], + bot_token: params[:bot_token], + bot_endpoint: params[:bot_endpoint], + token: SecureRandom.urlsafe_base64(48), + organization_id: params[:organization_id] + }, + group_id: params[:group_id], + active: true + ) + rescue StandardError => e + raise Exceptions::UnprocessableEntity, e.message + end + render json: channel + end + + def update + errors = {} + errors['group_id'] = 'required' if params[:group_id].blank? + + if errors.present? + render json: { + errors: errors + }, status: :bad_request + return + end + channel = Channel.find_by(id: params[:id], area: 'DeltaChat::Account') + begin + channel.options[:email_address] = params[:email_address] + channel.options[:bot_token] = params[:bot_token] + channel.options[:bot_endpoint] = params[:bot_endpoint] + channel.options[:organization_id] = params[:organization_id] + channel.group_id = params[:group_id] + channel.save! + rescue StandardError => e + raise Exceptions::UnprocessableEntity, e.message + end + render json: channel + end + + def rotate_token + channel = Channel.find_by(id: params[:id], area: 'DeltaChat::Account') + channel.options[:token] = SecureRandom.urlsafe_base64(48) + channel.save! + render json: {} + end + + def enable + channel = Channel.find_by(id: params[:id], area: 'DeltaChat::Account') + channel.active = true + channel.save! + render json: {} + end + + def disable + channel = Channel.find_by(id: params[:id], area: 'DeltaChat::Account') + channel.active = false + channel.save! + render json: {} + end + + def destroy + channel = Channel.find_by(id: params[:id], area: 'DeltaChat::Account') + channel.destroy + render json: {} + end + + def channel_for_token(token) + return false unless token + + Channel.where(area: 'DeltaChat::Account').each do |channel| + return channel if channel.options[:token] == token + end + false + end + + def channel_for_bot_token(bot_token) + return false unless bot_token + + Channel.where(area: 'DeltaChat::Account').each do |channel| + return channel if channel.options[:bot_token] == bot_token + end + false + end + + def bot_webhook + bot_token = params['bot_token'] + return render json: {}, status: :unauthorized unless bot_token + + channel = channel_for_bot_token(bot_token) + return render json: { error: 'Channel not found' }, status: :not_found if !channel || !channel.active + + # Use the channel's webhook token to reuse existing logic + params[:token] = channel.options[:token] + + webhook + end + + def webhook + token = params['token'] + return render json: {}, status: :unauthorized unless token + + channel = channel_for_token(token) + return render json: {}, status: :unauthorized if !channel || !channel.active + return render json: {}, status: :unauthorized if channel.options[:token] != token + + channel_id = channel.id + + # validate input + errors = {} + + %i[from + to + message_id + sent_at].each do |field| + errors[field] = 'required' if params[field].blank? + end + + if errors.present? + render json: { + errors: errors + }, status: :bad_request + return + end + + message_id = params[:message_id] + + return if Ticket::Article.exists?(message_id: "cdr_deltachat.#{message_id}") + + sender_email = params[:from].strip + bot_email = params[:to].strip + + # Customer lookup by email + customer = User.find_by(email: sender_email) + + unless customer + role_ids = Role.signup_role_ids + customer = User.create( + firstname: '', + lastname: '', + email: sender_email, + password: '', + note: 'CDR DeltaChat', + active: true, + role_ids: role_ids, + updated_by_id: 1, + created_by_id: 1 + ) + end + + # set current user + UserInfo.current_user_id = customer.id + current_user_set(customer, 'token_auth') + + group = Group.find_by(id: channel.group_id) + if group.blank? + Rails.logger.error "DeltaChat channel #{channel_id} paired with Group #{channel.group_id}, but group does not exist!" + return render json: { error: 'There was an error during DeltaChat submission' }, status: :internal_server_error + end + + organization_id = channel.options['organization_id'] + if organization_id.present? + organization = Organization.find_by(id: organization_id) + if organization.blank? + Rails.logger.error "DeltaChat channel #{channel_id} paired with Organization #{organization_id}, but organization does not exist!" + return render json: { error: 'There was an error during DeltaChat submission' }, status: :internal_server_error + end + if customer.organization_id.blank? + customer.organization_id = organization.id + customer.save! + end + end + + message = params[:message] ||= 'No text content' + sent_at = params[:sent_at] + attachment_data_base64 = params[:attachment] + attachment_filename = params[:filename] + attachment_mimetype = params[:mime_type] + title = "Message from #{sender_email} at #{sent_at}" + body = message + + # find ticket or create one + state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id) + ticket = Ticket.where(customer_id: customer.id).where.not(state_id: state_ids).order(:updated_at).first + if ticket + # check if title need to be updated + ticket.title = title if ticket.title == '-' + new_state = Ticket::State.find_by(default_create: true) + ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id + else + ticket = Ticket.new( + group_id: channel.group_id, + title: title, + customer_id: customer.id, + preferences: { + channel_id: channel.id, + cdr_deltachat: { + bot_token: channel.options[:bot_token], + chat_id: sender_email + } + } + ) + end + + ticket.save! + + article_params = { + from: sender_email, + to: bot_email, + sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id, + subject: title, + body: body, + content_type: 'text/plain', + message_id: "cdr_deltachat.#{message_id}", + ticket_id: ticket.id, + internal: false, + preferences: { + cdr_deltachat: { + timestamp: sent_at, + message_id: message_id, + from: sender_email + } + } + } + + if attachment_data_base64.present? + article_params[:attachments] = [ + { + 'filename' => attachment_filename, + :filename => attachment_filename, + :data => attachment_data_base64, + 'data' => attachment_data_base64, + 'mime-type' => attachment_mimetype + } + ] + end + + # setting the article type after saving seems to be the only way to get it to stick + ticket.with_lock do + ta = article_create(ticket, article_params) + ta.update!(type_id: Ticket::Article::Type.find_by(name: 'cdr_deltachat').id) + end + + ticket.update!(create_article_type_id: Ticket::Article::Type.find_by(name: 'cdr_deltachat').id) + + result = { + ticket: { + id: ticket.id, + number: ticket.number + } + } + + render json: result, status: :ok + end +end diff --git a/packages/zammad-addon-link/src/app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/article-type/plugins/deltachatMessage.ts b/packages/zammad-addon-link/src/app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/article-type/plugins/deltachatMessage.ts new file mode 100644 index 0000000..1056a83 --- /dev/null +++ b/packages/zammad-addon-link/src/app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/article-type/plugins/deltachatMessage.ts @@ -0,0 +1,7 @@ +import type { ChannelModule } from "#desktop/pages/ticket/components/TicketDetailView/article-type/types.ts"; + +export default { + name: "deltachat message", + label: __("DeltaChat Message"), + icon: "cdr-deltachat", +}; diff --git a/packages/zammad-addon-link/src/app/frontend/shared/entities/ticket-article/action/plugins/cdr_deltachat.ts b/packages/zammad-addon-link/src/app/frontend/shared/entities/ticket-article/action/plugins/cdr_deltachat.ts new file mode 100644 index 0000000..44dc941 --- /dev/null +++ b/packages/zammad-addon-link/src/app/frontend/shared/entities/ticket-article/action/plugins/cdr_deltachat.ts @@ -0,0 +1,69 @@ +import { EnumTicketArticleSenderName } from '#shared/graphql/types.ts' + +import type { TicketArticleAction, TicketArticleActionPlugin, TicketArticleType } from './types.ts' + +const actionPlugin: TicketArticleActionPlugin = { + order: 370, + + addActions(ticket, article) { + const sender = article.sender?.name + const type = article.type?.name + + if (sender !== EnumTicketArticleSenderName.Customer || type !== 'deltachat message') + return [] + + const action: TicketArticleAction = { + apps: ['mobile', 'desktop'], + label: __('Reply'), + name: 'deltachat message', + icon: 'cdr-deltachat', + view: { + agent: ['change'], + }, + perform(ticket, article, { openReplyForm }) { + const articleData = { + articleType: type, + inReplyTo: article.messageId, + } + + openReplyForm(articleData) + }, + } + return [action] + }, + + addTypes(ticket) { + const descriptionType = ticket.createArticleType?.name + + if (descriptionType !== 'deltachat message') return [] + + const type: TicketArticleType = { + apps: ['mobile', 'desktop'], + value: 'deltachat message', + label: __('DeltaChat'), + buttonLabel: __('Add DeltaChat message'), + icon: 'cdr-deltachat', + view: { + agent: ['change'], + }, + internal: false, + contentType: 'text/plain', + fields: { + body: { + required: true, + validation: 'length:1,10000', + }, + attachments: {}, + }, + editorMeta: { + footer: { + maxlength: 10000, + warningLength: 5000, + }, + }, + } + return [type] + }, +} + +export default actionPlugin diff --git a/packages/zammad-addon-link/src/app/jobs/communicate_cdr_deltachat_job.rb b/packages/zammad-addon-link/src/app/jobs/communicate_cdr_deltachat_job.rb new file mode 100644 index 0000000..25cbbcc --- /dev/null +++ b/packages/zammad-addon-link/src/app/jobs/communicate_cdr_deltachat_job.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +class CommunicateCdrDeltachatJob < ApplicationJob + retry_on StandardError, attempts: 4, wait: lambda { |executions| + executions * 120.seconds + } + + def perform(article_id) + article = Ticket::Article.find(article_id) + + # set retry count + article.preferences['delivery_retry'] ||= 0 + article.preferences['delivery_retry'] += 1 + + ticket = Ticket.lookup(id: article.ticket_id) + unless ticket.preferences + log_error(article, + "Can't find ticket.preferences for Ticket.find(#{article.ticket_id})") + end + unless ticket.preferences['cdr_deltachat'] + log_error(article, + "Can't find ticket.preferences['cdr_deltachat'] for Ticket.find(#{article.ticket_id})") + end + unless ticket.preferences['cdr_deltachat']['bot_token'] + log_error(article, + "Can't find ticket.preferences['cdr_deltachat']['bot_token'] for Ticket.find(#{article.ticket_id})") + end + unless ticket.preferences['cdr_deltachat']['chat_id'] + log_error(article, + "Can't find ticket.preferences['cdr_deltachat']['chat_id'] for Ticket.find(#{article.ticket_id})") + end + channel = ::CdrDeltachat.bot_by_bot_token(ticket.preferences['cdr_deltachat']['bot_token']) + channel ||= ::Channel.lookup(id: ticket.preferences['channel_id']) + unless channel + log_error(article, + "No such channel for bot #{ticket.preferences['cdr_deltachat']['bot_token']} or channel id #{ticket.preferences['channel_id']}") + end + if channel.options[:bot_token].blank? + log_error(article, + "Channel.find(#{channel.id}) has no cdr deltachat api token!") + end + + has_error = false + + begin + result = channel.deliver(article) + rescue StandardError => e + log_error(article, e.message) + has_error = true + end + + Rails.logger.debug { "send result: #{result}" } + + if result.nil? || result[:error].present? + log_error(article, 'Delivering deltachat message failed!') + has_error = true + end + + return if has_error + + article.to = result['result']['to'] + article.from = result['result']['from'] + + message_id = format('%s@%s', from: result['result']['from'], + timestamp: result['result']['timestamp']) + article.preferences['cdr_deltachat'] = { + timestamp: result['result']['timestamp'], + message_id: message_id, + from: result['result']['from'], + to: result['result']['to'] + } + + # set delivery status + article.preferences['delivery_status_message'] = nil + article.preferences['delivery_status'] = 'success' + article.preferences['delivery_status_date'] = Time.zone.now + + article.message_id = "cdr_deltachat.#{message_id}" + + article.save! + + Rails.logger.info "Sent deltachat message to: '#{article.to}' (from #{article.from})" + + article + end + + def log_error(local_record, message) + local_record.preferences['delivery_status'] = 'fail' + local_record.preferences['delivery_status_message'] = + message.encode!('UTF-8', 'UTF-8', invalid: :replace, replace: '?') + local_record.preferences['delivery_status_date'] = Time.zone.now + local_record.save + Rails.logger.error message + + if local_record.preferences['delivery_retry'] > 3 + Ticket::Article.create( + ticket_id: local_record.ticket_id, + content_type: 'text/plain', + body: "Unable to send cdr deltachat message: #{message}", + internal: true, + sender: Ticket::Article::Sender.find_by(name: 'System'), + type: Ticket::Article::Type.find_by(name: 'note'), + preferences: { + delivery_article_id_related: local_record.id, + delivery_message: true + }, + updated_by_id: 1, + created_by_id: 1 + ) + end + + raise message + end +end diff --git a/packages/zammad-addon-link/src/app/models/channel/driver/cdr_deltachat.rb b/packages/zammad-addon-link/src/app/models/channel/driver/cdr_deltachat.rb new file mode 100644 index 0000000..d9df225 --- /dev/null +++ b/packages/zammad-addon-link/src/app/models/channel/driver/cdr_deltachat.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class Channel::Driver::CdrDeltachat + def fetchable?(_channel) + false + end + + def disconnect; end + + def deliver(options, article, _notification = false) + # return if we run import mode + return if Setting.get('import_mode') + + options = check_external_credential(options) + + Rails.logger.debug { 'deltachat send started' } + Rails.logger.debug { options.inspect } + @deltachat = ::CdrDeltachat.new(options[:bot_endpoint], options[:bot_token]) + @deltachat.from_article(article) + end + + private + + def check_external_credential(options) + if options[:auth] && options[:auth][:external_credential_id] + external_credential = ExternalCredential.find_by(id: options[:auth][:external_credential_id]) + raise "No such ExternalCredential.find(#{options[:auth][:external_credential_id]})" unless external_credential + + options[:auth][:api_key] = external_credential.credentials['api_key'] + end + options + end +end diff --git a/packages/zammad-addon-link/src/app/models/ticket/article/enqueue_communicate_cdr_deltachat_job.rb b/packages/zammad-addon-link/src/app/models/ticket/article/enqueue_communicate_cdr_deltachat_job.rb new file mode 100644 index 0000000..bb1b05c --- /dev/null +++ b/packages/zammad-addon-link/src/app/models/ticket/article/enqueue_communicate_cdr_deltachat_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Ticket::Article::EnqueueCommunicateCdrDeltachatJob + extend ActiveSupport::Concern + + included do + after_create :ticket_article_enqueue_communicate_cdr_deltachat_job + end + + private + + def ticket_article_enqueue_communicate_cdr_deltachat_job + # return if we run import mode + return true if Setting.get('import_mode') + + # if sender is customer, do not communicate + return true unless sender_id + + sender = Ticket::Article::Sender.lookup(id: sender_id) + return true if sender.nil? + return true if sender.name == 'Customer' + + # only apply on cdr deltachat messages + return true unless type_id + + type = Ticket::Article::Type.lookup(id: type_id) + return true unless type.name.match?(/\Acdr_deltachat/i) + + CommunicateCdrDeltachatJob.perform_later(id) + end +end diff --git a/packages/zammad-addon-link/src/app/policies/controllers/channels_cdr_deltachat_controller_policy.rb b/packages/zammad-addon-link/src/app/policies/controllers/channels_cdr_deltachat_controller_policy.rb new file mode 100644 index 0000000..4a19e2e --- /dev/null +++ b/packages/zammad-addon-link/src/app/policies/controllers/channels_cdr_deltachat_controller_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Controllers + class ChannelsCdrDeltachatControllerPolicy < Controllers::ApplicationControllerPolicy + default_permit!('admin.channel_cdr_deltachat') + end +end diff --git a/packages/zammad-addon-link/src/config/initializers/cdr_deltachat.rb b/packages/zammad-addon-link/src/config/initializers/cdr_deltachat.rb new file mode 100644 index 0000000..4c517e9 --- /dev/null +++ b/packages/zammad-addon-link/src/config/initializers/cdr_deltachat.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +Rails.application.config.after_initialize do + class Ticket::Article + include Ticket::Article::EnqueueCommunicateCdrDeltachatJob + end + + icon = File.read('public/assets/images/icons/cdr_deltachat.svg') + doc = File.open('public/assets/images/icons.svg') { |f| Nokogiri::XML(f) } + if !doc.at_css('#icon-cdr-deltachat') + doc.at('svg').add_child(icon) + Rails.logger.debug 'deltachat icon added to icon set' + else + Rails.logger.debug 'deltachat icon already in icon set' + end + File.write('public/assets/images/icons.svg', doc.to_xml) +end diff --git a/packages/zammad-addon-link/src/config/routes/channel_cdr_deltachat.rb b/packages/zammad-addon-link/src/config/routes/channel_cdr_deltachat.rb new file mode 100644 index 0000000..eb03427 --- /dev/null +++ b/packages/zammad-addon-link/src/config/routes/channel_cdr_deltachat.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +Zammad::Application.routes.draw do + api_path = Rails.configuration.api_path + + match "#{api_path}/channels_cdr_deltachat", to: 'channels_cdr_deltachat#index', via: :get + match "#{api_path}/channels_cdr_deltachat", to: 'channels_cdr_deltachat#add', via: :post + match "#{api_path}/channels_cdr_deltachat/:id", to: 'channels_cdr_deltachat#update', via: :put + match "#{api_path}/channels_cdr_deltachat_webhook/:token", to: 'channels_cdr_deltachat#webhook', via: :post + match "#{api_path}/channels_cdr_deltachat_bot_webhook/:bot_token", to: 'channels_cdr_deltachat#bot_webhook', via: :post + match "#{api_path}/channels_cdr_deltachat_disable", to: 'channels_cdr_deltachat#disable', via: :post + match "#{api_path}/channels_cdr_deltachat_enable", to: 'channels_cdr_deltachat#enable', via: :post + match "#{api_path}/channels_cdr_deltachat", to: 'channels_cdr_deltachat#destroy', via: :delete + match "#{api_path}/channels_cdr_deltachat_rotate_token", to: 'channels_cdr_deltachat#rotate_token', via: :post +end diff --git a/packages/zammad-addon-link/src/db/addon/link/20260214000001_cdr_deltachat_channel.rb b/packages/zammad-addon-link/src/db/addon/link/20260214000001_cdr_deltachat_channel.rb new file mode 100644 index 0000000..3120430 --- /dev/null +++ b/packages/zammad-addon-link/src/db/addon/link/20260214000001_cdr_deltachat_channel.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class CdrDeltachatChannel < ActiveRecord::Migration[5.2] + def self.up + Ticket::Article::Type.create_if_not_exists( + name: 'cdr_deltachat', + communication: true, + updated_by_id: 1, + created_by_id: 1 + ) + Permission.create_if_not_exists( + name: 'admin.channel_cdr_deltachat', + description: 'Manage %s', + preferences: { + translations: ['Channel - DeltaChat'] + } + ) + end + + def self.down + t = Ticket::Article::Type.find_by(name: 'cdr_deltachat') + t&.destroy + + p = Permission.find_by(name: 'admin.channel_cdr_deltachat') + p&.destroy + end +end diff --git a/packages/zammad-addon-link/src/lib/cdr_deltachat.rb b/packages/zammad-addon-link/src/lib/cdr_deltachat.rb new file mode 100644 index 0000000..ad22770 --- /dev/null +++ b/packages/zammad-addon-link/src/lib/cdr_deltachat.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'cdr_deltachat_api' + +class CdrDeltachat + attr_accessor :client + + def self.check_token(api_url, token) + api = CdrDeltachatApi.new(api_url, token) + begin + bot = api.fetch_self + rescue StandardError => e + raise "invalid api token: #{e.message}" + end + bot + end + + def self.bot_by_bot_token(bot_token) + Channel.where(area: 'DeltaChat::Account').each do |channel| + next unless channel.options + next unless channel.options[:bot_token] + return channel if channel.options[:bot_token].to_s == bot_token.to_s + end + nil + end + + def self.timestamp_to_date(timestamp_str) + Time.at(timestamp_str.to_i).utc.to_datetime + end + + def self.message_id(message_raw) + format('%s@%s', from: message_raw['from'], timestamp: message_raw['timestamp']) + end + + def initialize(api_url, token) + @token = token + @api_url = api_url + @api = CdrDeltachatApi.new(api_url, token) + end + + def send_message(recipient, message) + return if Rails.env.test? + + @api.send_message(recipient, message) + end + + def from_article(article) + Rails.logger.debug { "Create deltachat message from article..." } + + ticket = Ticket.find_by(id: article.ticket_id) + raise "No ticket found for article #{article.id}" unless ticket + + recipient = ticket.preferences.dig('cdr_deltachat', 'chat_id') + raise "No DeltaChat chat_id found in ticket preferences" unless recipient + + Rails.logger.debug { "Sending to recipient: '#{recipient}'" } + + options = {} + + attachments = Store.list(object: 'Ticket::Article', o_id: article.id) + if attachments.any? + attachment_data = attachments.map do |attachment| + { + data: Base64.strict_encode64(attachment.content), + filename: attachment.filename, + mime_type: attachment.preferences['Mime-Type'] || attachment.preferences['Content-Type'] || 'application/octet-stream' + } + end + options[:attachments] = attachment_data + Rails.logger.debug { "Sending #{attachment_data.length} attachment(s) with message" } + end + + @api.send_message(recipient, article[:body], options) + end +end diff --git a/packages/zammad-addon-link/src/lib/cdr_deltachat_api.rb b/packages/zammad-addon-link/src/lib/cdr_deltachat_api.rb new file mode 100644 index 0000000..e37eeda --- /dev/null +++ b/packages/zammad-addon-link/src/lib/cdr_deltachat_api.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'json' +require 'net/http' +require 'net/https' +require 'uri' + +class CdrDeltachatApi + def initialize(api_url, token) + @token = token + @last_update = 0 + @api_url = ENV.fetch('BRIDGE_DELTACHAT_URL', api_url || 'http://bridge-deltachat:5001') + end + + def get(api) + url = "#{@api_url}/api/bots/#{@token}/#{api}" + response = Faraday.get(url, nil, { 'Accept' => 'application/json' }) + return {} unless response.success? + + JSON.parse(response.body) + rescue JSON::ParserError, Faraday::Error => e + Rails.logger.error "CdrDeltachatApi: GET #{api} failed: #{e.message}" + {} + end + + def post(api, params = {}) + url = "#{@api_url}/api/bots/#{@token}/#{api}" + response = Faraday.post(url, params.to_json, { + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + }) + + unless response.success? + Rails.logger.error "CdrDeltachatApi: POST #{api} failed: #{response.status} #{response.body}" + raise "Failed to call DeltaChat API: #{response.status}" + end + + JSON.parse(response.body) + rescue JSON::ParserError => e + Rails.logger.error "CdrDeltachatApi: Failed to parse response: #{e.message}" + {} + rescue Faraday::Error => e + Rails.logger.error "CdrDeltachatApi: POST #{api} failed: #{e.message}" + raise "Failed to call DeltaChat API: #{e.message}" + end + + def fetch_self + get('') + end + + def send_message(recipient, text, options = {}) + params = { + email: recipient.to_s, + message: text + } + + if options[:attachments].present? + params[:attachments] = options[:attachments].map do |att| + { + data: att[:data], + filename: att[:filename], + mime_type: att[:mime_type] + } + end + end + + result = post('send', params) + + { + 'result' => { + 'to' => result.dig('result', 'recipient') || recipient, + 'from' => result.dig('result', 'source') || @token, + 'timestamp' => result.dig('result', 'timestamp') || Time.current.iso8601 + } + } + end +end diff --git a/packages/zammad-addon-link/src/public/assets/images/icons/cdr_deltachat.svg b/packages/zammad-addon-link/src/public/assets/images/icons/cdr_deltachat.svg new file mode 100644 index 0000000..e0fd601 --- /dev/null +++ b/packages/zammad-addon-link/src/public/assets/images/icons/cdr_deltachat.svg @@ -0,0 +1,5 @@ +deltachat + + + +