From 88df89b08caae27856b605712d2b9439724f6557 Mon Sep 17 00:00:00 2001 From: Jiri Spac Date: Mon, 21 Sep 2026 03:28:02 +0200 Subject: [PATCH 1/3] Add invoice and payment reminder emails with editable localized templates --- e2e/invoice-email.spec.ts | 163 +++++++++++ faktorio-api/.dev.vars.e2e | 1 + faktorio-api/.dev.vars.example | 1 + faktorio-api/src/envSchema.ts | 1 + .../src/routers/invoices/invoiceEmail.spec.ts | 191 +++++++++++++ .../src/routers/invoices/invoiceEmail.ts | 150 +++++++++++ .../invoices/invoiceEmailTemplate.spec.ts | 33 +++ .../src/routers/invoices/invoiceRouter.ts | 3 + faktorio-api/src/sendEmail.ts | 18 +- faktorio-api/src/trpcContext.ts | 7 +- faktorio-api/wrangler.toml | 1 + .../src/components/FeatureComparison.tsx | 2 +- .../pages/InvoiceDetail/InvoiceDetailPage.tsx | 7 + .../InvoiceDetail/InvoiceEmailControls.tsx | 252 ++++++++++++++++++ faktorio-shared/src/invoiceEmail.ts | 48 ++++ readme.md | 6 +- roadmap.md | 1 - 17 files changed, 871 insertions(+), 14 deletions(-) create mode 100644 e2e/invoice-email.spec.ts create mode 100644 faktorio-api/src/routers/invoices/invoiceEmail.spec.ts create mode 100644 faktorio-api/src/routers/invoices/invoiceEmail.ts create mode 100644 faktorio-api/src/routers/invoices/invoiceEmailTemplate.spec.ts create mode 100644 faktorio-fe/src/pages/InvoiceDetail/InvoiceEmailControls.tsx create mode 100644 faktorio-shared/src/invoiceEmail.ts diff --git a/e2e/invoice-email.spec.ts b/e2e/invoice-email.spec.ts new file mode 100644 index 00000000..2f1948cc --- /dev/null +++ b/e2e/invoice-email.spec.ts @@ -0,0 +1,163 @@ +import { expect, test } from './fixtures' + +test('invoice email composer uses the contact language, preserves edits and sends reminders', async ({ + page +}) => { + await page.addInitScript(() => { + localStorage.setItem('auth_token', 'email-composer-test-token') + localStorage.setItem( + 'auth_user', + JSON.stringify({ + id: 'email-test-user', + name: 'Seller', + email: 'seller@example.com' + }) + ) + }) + const invoice = { + id: 'email-test-invoice', + number: '2026-123', + language: 'cs', + your_name: 'Seller', + your_street: 'Street 1', + your_city: 'Prague', + your_zip: '11000', + your_country: 'CZ', + your_registration_no: '12345678', + your_vat_no: '', + client_name: 'Client', + client_street: 'Street 2', + client_city: 'London', + client_zip: '12345', + client_country: 'GB', + issued_on: '2026-09-01', + taxable_fulfillment_due: '2026-09-01', + due_on: '2026-09-15', + due_in_days: 14, + bank_account: '123456789/0100', + currency: 'CZK', + total: 100, + paid_on: null, + status: null, + cancelled_at: null, + sent_at: null, + reminder_sent_at: null, + items: [ + { + id: 1, + description: 'Work', + quantity: 1, + unit_price: 100, + vat_rate: 0, + unit: 'ks' + } + ] + } + const sends: Record[] = [] + let failSend = false + await page.route('**/trpc/**', async (route) => { + const url = new URL(route.request().url()) + const procedures = url.pathname.split('/trpc/')[1].split(',') + const inputs = JSON.parse( + route.request().postData() || url.searchParams.get('input') || '{}' + ) + const response = procedures.map((procedure, index) => { + let json: unknown = null + if (procedure === 'invoices.getById') json = invoice + if (procedure === 'invoicingDetails') + json = { name: 'Seller', vat_payer: false, bankAccounts: [] } + if (procedure === 'invoices.listShares') json = [] + if (procedure === 'invoices.getEmailDraft') + json = { + to: 'client@example.com', + language: 'en', + number: invoice.number, + dueOn: invoice.due_on, + senderName: 'Seller' + } + if (procedure === 'invoices.sendEmail') { + if (failSend) + return { + error: { + json: { + message: 'E-mail se nepodařilo odeslat.', + code: -32603, + data: { + code: 'INTERNAL_SERVER_ERROR', + httpStatus: 500, + path: procedure + } + } + } + } + sends.push(inputs[index].json) + json = { sentAt: '2026-09-21' } + } + return { result: { data: { json } } } + }) + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify(response) + }) + }) + await page.goto('http://localhost:5173/invoices/email-test-invoice') + await page + .getByRole('button', { name: 'Odeslat fakturu', exact: true }) + .click() + const dialog = page.getByRole('dialog') + const language = dialog.getByLabel('Jazyk e-mailu', { exact: true }) + const subject = dialog.getByLabel('Předmět', { exact: true }) + const body = dialog.getByLabel('Zpráva', { exact: true }) + await expect(language).toHaveValue('en') + await expect(subject).toHaveValue('Invoice 2026-123') + await expect(dialog.getByLabel('Příjemce')).toHaveValue('client@example.com') + await language.selectOption('cs') + await expect(subject).toHaveValue('Faktura 2026-123') + await body.fill('Vlastní zpráva') + page.once('dialog', (confirmation) => confirmation.dismiss()) + await language.selectOption('en') + await expect(language).toHaveValue('cs') + await expect(body).toHaveValue('Vlastní zpráva') + page.once('dialog', (confirmation) => confirmation.accept()) + await language.selectOption('en') + await expect(body).toHaveValue(/Hello,/) + await subject.fill('Custom subject') + await body.fill('Please review my invoice.') + await dialog.getByLabel('Příjemce').fill('other@example.com') + // A background query refetch must not reset the draft. + await page.evaluate(() => window.dispatchEvent(new Event('focus'))) + await expect(body).toHaveValue('Please review my invoice.') + failSend = true + await dialog + .getByRole('button', { name: 'Odeslat e-mail', exact: true }) + .click() + await expect( + page.getByText('E-mail se nepodařilo odeslat.').first() + ).toBeVisible() + await expect(body).toHaveValue('Please review my invoice.') + failSend = false + await dialog + .getByRole('button', { name: 'Odeslat e-mail', exact: true }) + .click() + await expect(dialog).not.toBeVisible() + expect(sends).toEqual([ + { + invoiceId: invoice.id, + kind: 'invoice', + language: 'en', + to: 'other@example.com', + subject: 'Custom subject', + body: 'Please review my invoice.' + } + ]) + await page + .getByRole('button', { name: 'Odeslat upomínku', exact: true }) + .click() + await expect(subject).toHaveValue('Payment reminder – invoice 2026-123') + await expect(body).toHaveValue(/remains unpaid/) + await dialog + .getByRole('button', { name: 'Odeslat e-mail', exact: true }) + .click() + await expect(dialog).not.toBeVisible() + expect(sends[1]).toMatchObject({ kind: 'reminder', language: 'en' }) +}) diff --git a/faktorio-api/.dev.vars.e2e b/faktorio-api/.dev.vars.e2e index 69dc7dc4..1756a203 100644 --- a/faktorio-api/.dev.vars.e2e +++ b/faktorio-api/.dev.vars.e2e @@ -2,6 +2,7 @@ TURSO_DATABASE_URL=http://127.0.0.1:8080 TURSO_AUTH_TOKEN=local JWT_SECRET=e2e-jwt-secret-not-used-anywhere-real-32-chars OPENROUTER_API_KEY=e2e-test-openrouter-key +PUBLIC_APP_URL=http://localhost:5173 VAPID_PUBLIC_KEY=BGs37P8xLBBboVi_dD2JWT6y8Kauh1iiXtxrs6tpy2edZCzYixA8BA6iQhm2rFBH7SSgR5xKHF23BkXkRWMS-fQ VAPID_PRIVATE_KEY=e2e-test-vapid-private-key VAPID_SUBJECT=mailto:e2e@test.local diff --git a/faktorio-api/.dev.vars.example b/faktorio-api/.dev.vars.example index 1c4dca48..e81c895d 100644 --- a/faktorio-api/.dev.vars.example +++ b/faktorio-api/.dev.vars.example @@ -2,4 +2,5 @@ TURSO_DATABASE_URL= TURSO_AUTH_TOKEN= JWT_SECRET= OPENROUTER_API_KEY= +PUBLIC_APP_URL=http://localhost:5173 INVOICE_LOGO_PUBLIC_BASE_URL=https://uploads.faktorio.cz diff --git a/faktorio-api/src/envSchema.ts b/faktorio-api/src/envSchema.ts index e09355d9..b7f302d4 100644 --- a/faktorio-api/src/envSchema.ts +++ b/faktorio-api/src/envSchema.ts @@ -12,6 +12,7 @@ export const envSchema = z.object({ OPENROUTER_API_KEY: z.string().min(1), // Supplied by Wrangler, not process.env; absent in browser-only local mode. SEND_EMAIL: z.custom().optional(), + PUBLIC_APP_URL: z.url().optional(), VAPID_PUBLIC_KEY: z.string().min(1), VAPID_PRIVATE_KEY: z.string().min(1), VAPID_SUBJECT: z.string().min(1), diff --git a/faktorio-api/src/routers/invoices/invoiceEmail.spec.ts b/faktorio-api/src/routers/invoices/invoiceEmail.spec.ts new file mode 100644 index 00000000..55f4bb8c --- /dev/null +++ b/faktorio-api/src/routers/invoices/invoiceEmail.spec.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createClient, type Client } from '@libsql/client' +import { drizzle } from 'drizzle-orm/libsql' +import { migrate } from 'drizzle-orm/libsql/migrator' +import { eq } from 'drizzle-orm' +import * as schema from 'faktorio-db/schema' +import { invoiceRouter } from './invoiceRouter' +import type { TrpcContext } from '../../trpcContext' +import { invoiceEmailTemplate } from 'faktorio-shared/src/invoiceEmail' + +let client: Client +let ctx: TrpcContext +const sendEmail = vi.fn() +const input = { + invoiceId: 'invoice-1', + kind: 'invoice' as const, + language: 'en' as const, + to: 'chosen@example.com', + subject: 'Custom subject', + body: 'Hello & team!\nPlease pay.' +} + +beforeEach(async () => { + client = createClient({ url: ':memory:' }) + const db = drizzle(client, { schema }) + await migrate(db, { migrationsFolder: '../faktorio-db/drizzle' }) + const [user] = await db + .insert(schema.userT) + .values({ id: 'owner', email: 'owner@example.com', name: 'Owner' }) + .returning() + await db + .insert(schema.contactTb) + .values({ + id: 'contact-1', + user_id: user.id, + name: 'Client', + language: 'en', + main_email: 'contact@example.com' + }) + await db.insert(schema.invoicesTb).values({ + id: 'invoice-1', + user_id: user.id, + client_contact_id: 'contact-1', + number: '2026-001', + language: 'cs', + your_name: 'Seller', + your_street: '', + your_city: '', + your_zip: '', + your_country: 'CZ', + your_registration_no: '', + your_vat_no: '', + client_name: 'Client', + client_street: '', + client_city: '', + issued_on: '2026-09-01', + taxable_fulfillment_due: '2026-09-01', + due_on: '2026-09-15', + due_in_days: 14, + payment_method: 'bank', + currency: 'CZK', + total: 100, + native_subtotal: 100 + }) + sendEmail.mockReset().mockResolvedValue(undefined) + ctx = { + db, + user, + env: { PUBLIC_APP_URL: 'https://app.example.com' } as TrpcContext['env'], + req: new Request('https://untrusted.example.com'), + sendEmail, + generateToken: vi.fn() + } +}) + +afterEach(() => client.close()) + +describe('invoice email', () => { + it('prefills from the contact language and address, without creating a public share', async () => { + const draft = await invoiceRouter + .createCaller(ctx) + .getEmailDraft({ invoiceId: 'invoice-1', kind: 'invoice' }) + expect(draft).toMatchObject({ + to: 'contact@example.com', + language: 'en', + number: '2026-001', + senderName: 'Seller' + }) + expect(invoiceEmailTemplate(draft, draft.language, 'invoice').subject).toBe( + 'Invoice 2026-001' + ) + expect(await ctx.db.query.invoiceShareTb.findMany()).toHaveLength(0) + }) + + it('sends edited content with a working share, escapes HTML and only changes the sent date', async () => { + await invoiceRouter.createCaller(ctx).sendEmail(input) + const [share] = await ctx.db.query.invoiceShareTb.findMany() + expect(share).toMatchObject({ + invoice_id: 'invoice-1', + user_id: 'owner', + disabled_at: null + }) + expect(sendEmail).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + to: { email: 'chosen@example.com', name: 'Client' }, + subject: input.subject, + replyTo: { email: 'owner@example.com', name: 'Seller' }, + text: `${input.body}\n\nView and download invoice: https://app.example.com/shared-invoice/${share.id}` + }) + ) + expect(sendEmail.mock.calls[0][0].html).toContain( + 'Hello <client> & team!
Please pay.' + ) + const invoice = await ctx.db.query.invoicesTb.findFirst() + expect(invoice).toMatchObject({ language: 'cs', reminder_sent_at: null }) + expect(invoice?.sent_at).toMatch(/^\d{4}-\d{2}-\d{2}$/) + expect((await ctx.db.query.contactTb.findFirst())?.language).toBe('en') + }) + + it('records reminders separately and uses the selected email language', async () => { + await invoiceRouter + .createCaller(ctx) + .sendEmail({ ...input, kind: 'reminder', language: 'cs' }) + expect(sendEmail.mock.calls[0][0].text).toContain( + 'Zobrazit a stáhnout fakturu:' + ) + const invoice = await ctx.db.query.invoicesTb.findFirst() + expect(invoice?.reminder_sent_at).toMatch(/^\d{4}-\d{2}-\d{2}$/) + expect(invoice?.sent_at).toBeNull() + }) + + it('does not mark failed emails sent and removes their unused public share', async () => { + sendEmail.mockRejectedValue(new Error('Provider failure')) + await expect( + invoiceRouter.createCaller(ctx).sendEmail(input) + ).rejects.toMatchObject({ code: 'INTERNAL_SERVER_ERROR' }) + expect(await ctx.db.query.invoiceShareTb.findMany()).toHaveLength(0) + expect(await ctx.db.query.invoicesTb.findFirst()).toMatchObject({ + sent_at: null, + reminder_sent_at: null + }) + }) + + it('rejects access to another user’s invoice before creating shares or sending', async () => { + const caller = invoiceRouter.createCaller({ + ...ctx, + user: { ...ctx.user!, id: 'someone-else' } + }) + await expect( + caller.getEmailDraft({ invoiceId: 'invoice-1', kind: 'invoice' }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }) + await expect(caller.sendEmail(input)).rejects.toMatchObject({ + code: 'NOT_FOUND' + }) + expect(sendEmail).not.toHaveBeenCalled() + expect(await ctx.db.query.invoiceShareTb.findMany()).toHaveLength(0) + }) + + it('requires authentication', async () => { + await expect( + invoiceRouter.createCaller({ ...ctx, user: undefined }).sendEmail(input) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }) + expect(sendEmail).not.toHaveBeenCalled() + }) + + it('rejects paid reminders and cancelled invoices', async () => { + await ctx.db + .update(schema.invoicesTb) + .set({ paid_on: '2026-09-10' }) + .where(eq(schema.invoicesTb.id, 'invoice-1')) + await expect( + invoiceRouter.createCaller(ctx).sendEmail({ ...input, kind: 'reminder' }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }) + await ctx.db.update(schema.invoicesTb).set({ cancelled_at: '2026-09-11' }) + await expect( + invoiceRouter.createCaller(ctx).sendEmail(input) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }) + expect(sendEmail).not.toHaveBeenCalled() + }) + + it.each([ + { to: 'invalid' }, + { subject: 'Hello\r\nBcc: other@example.com' }, + { body: ' ' } + ])('rejects invalid email input %j', async (invalid) => { + await expect( + invoiceRouter.createCaller(ctx).sendEmail({ ...input, ...invalid }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }) + expect(sendEmail).not.toHaveBeenCalled() + }) +}) diff --git a/faktorio-api/src/routers/invoices/invoiceEmail.ts b/faktorio-api/src/routers/invoices/invoiceEmail.ts new file mode 100644 index 00000000..adce0e47 --- /dev/null +++ b/faktorio-api/src/routers/invoices/invoiceEmail.ts @@ -0,0 +1,150 @@ +import { TRPCError } from '@trpc/server' +import { and, eq } from 'drizzle-orm' +import { + contactTb, + invoicesTb, + invoiceShareTb, + userInvoicingDetailsTb +} from 'faktorio-db/schema' +import { djs } from 'faktorio-shared/src/djs' +import { invoiceEmailLanguage } from 'faktorio-shared/src/invoiceEmail' +import { z } from 'zod/v4' +import { protectedProc } from '../../isAuthorizedMiddleware' +import type { TrpcContext } from '../../trpcContext' + +const emailInput = z.object({ + invoiceId: z.string().min(1), + kind: z.enum(['invoice', 'reminder']) +}) + +async function ownedInvoice(ctx: TrpcContext, invoiceId: string) { + const invoice = await ctx.db.query.invoicesTb.findFirst({ + where: and( + eq(invoicesTb.id, invoiceId), + eq(invoicesTb.user_id, ctx.user!.id) + ) + }) + if (!invoice) + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Faktura nebyla nalezena.' + }) + return invoice +} + +function checkSendable( + invoice: typeof invoicesTb.$inferSelect, + kind: 'invoice' | 'reminder' +) { + if (invoice.cancelled_at) + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Zrušenou fakturu nelze odeslat.' + }) + if (kind === 'reminder' && (invoice.paid_on || invoice.status === 'paid')) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Zaplacenou fakturu nelze upomínat.' + }) + } +} + +export const getEmailDraft = protectedProc + .input(emailInput) + .query(async ({ ctx, input }) => { + const invoice = await ownedInvoice(ctx, input.invoiceId) + checkSendable(invoice, input.kind) + const contact = await ctx.db.query.contactTb.findFirst({ + where: and( + eq(contactTb.id, invoice.client_contact_id), + eq(contactTb.user_id, ctx.user.id) + ) + }) + return { + to: contact?.main_email || contact?.email || invoice.client_email || '', + language: invoiceEmailLanguage(contact?.language, invoice.language), + number: invoice.number, + dueOn: invoice.due_on, + senderName: invoice.your_name + } + }) + +const escapeHtml = (value: string) => + value.replace( + /[&<>"']/g, + (char) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + })[char]! + ) + +export const sendInvoiceEmail = protectedProc + .input( + emailInput.extend({ + to: z.string().trim().email().max(254), + language: z.enum(['cs', 'en']), + subject: z + .string() + .trim() + .min(1) + .max(200) + .regex(/^[^\r\n]+$/), + body: z.string().trim().min(1).max(20000) + }) + ) + .mutation(async ({ ctx, input }) => { + const invoice = await ownedInvoice(ctx, input.invoiceId) + checkSendable(invoice, input.kind) + const details = await ctx.db.query.userInvoicingDetailsTb.findFirst({ + where: eq(userInvoicingDetailsTb.user_id, ctx.user.id) + }) + const replyTo = z.email().safeParse(details?.main_email || ctx.user.email) + // Use a configured origin, never a client-supplied URL or Host header. + const base = new URL(ctx.env.PUBLIC_APP_URL ?? 'https://faktorio.cz') + const [share] = await ctx.db + .insert(invoiceShareTb) + .values({ + invoice_id: invoice.id, + user_id: ctx.user.id + }) + .returning() + const link = new URL(`/shared-invoice/${share.id}`, base).href + const linkLabel = + input.language === 'cs' + ? 'Zobrazit a stáhnout fakturu' + : 'View and download invoice' + try { + await ctx.sendEmail({ + to: { email: input.to, name: invoice.client_name }, + subject: input.subject, + text: `${input.body}\n\n${linkLabel}: ${link}`, + html: `
${escapeHtml(input.body).replace(/\r?\n/g, '
')}

${linkLabel}

`, + ...(replyTo.success + ? { replyTo: { email: replyTo.data, name: invoice.your_name } } + : {}) + }) + } catch { + // A failed send must not leave an unused public share or mark the invoice sent. + await ctx.db.delete(invoiceShareTb).where(eq(invoiceShareTb.id, share.id)) + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'E-mail se nepodařilo odeslat. Zkuste to prosím znovu.' + }) + } + const sentAt = djs().format('YYYY-MM-DD') + await ctx.db + .update(invoicesTb) + .set( + input.kind === 'invoice' + ? { sent_at: sentAt } + : { reminder_sent_at: sentAt } + ) + .where( + and(eq(invoicesTb.id, invoice.id), eq(invoicesTb.user_id, ctx.user.id)) + ) + return { sentAt } + }) diff --git a/faktorio-api/src/routers/invoices/invoiceEmailTemplate.spec.ts b/faktorio-api/src/routers/invoices/invoiceEmailTemplate.spec.ts new file mode 100644 index 00000000..5443e01f --- /dev/null +++ b/faktorio-api/src/routers/invoices/invoiceEmailTemplate.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { + invoiceEmailLanguage, + invoiceEmailTemplate +} from 'faktorio-shared/src/invoiceEmail' + +const data = { number: '2026-123', dueOn: '2026-09-21', senderName: 'Seller' } + +describe('invoice email templates', () => { + it('uses the contact language before the invoice language, with Czech fallback', () => { + expect(invoiceEmailLanguage('en', 'cs')).toBe('en') + expect(invoiceEmailLanguage('cs', 'en')).toBe('cs') + expect(invoiceEmailLanguage(null, 'en')).toBe('en') + expect(invoiceEmailLanguage('unsupported')).toBe('cs') + }) + it.each(['cs', 'en'] as const)( + 'provides invoice and reminder text in %s', + (language) => { + const invoice = invoiceEmailTemplate(data, language, 'invoice') + const reminder = invoiceEmailTemplate(data, language, 'reminder') + expect(invoice.subject).toContain(data.number) + expect(reminder.subject).toContain(data.number) + expect(invoice.body).toContain( + language === 'cs' ? '21. 9. 2026' : data.dueOn + ) + expect(reminder.body).toContain( + language === 'cs' ? 'neuhrazenou' : 'remains unpaid' + ) + expect(invoice.body).toContain(data.senderName) + expect(reminder.body).not.toBe(invoice.body) + } + ) +}) diff --git a/faktorio-api/src/routers/invoices/invoiceRouter.ts b/faktorio-api/src/routers/invoices/invoiceRouter.ts index c53a5186..30a14c51 100644 --- a/faktorio-api/src/routers/invoices/invoiceRouter.ts +++ b/faktorio-api/src/routers/invoices/invoiceRouter.ts @@ -27,6 +27,7 @@ import { invoiceItemFormSchema } from '../../zodDbSchemas' import { getInvoiceSums } from './getInvoiceSums' import { getCNBExchangeRate } from './getCNBExchangeRate' import { invoiceShareEventTb, invoiceShareTb } from 'faktorio-db/schema' +import { getEmailDraft, sendInvoiceEmail } from './invoiceEmail' const invoiceSchema = getInvoiceCreateSchema(djs().format('YYYYMMDD') + '001') @@ -54,6 +55,8 @@ const updateInvoiceInput = z.object({ }) export const invoiceRouter = trpcContext.router({ + getEmailDraft, + sendEmail: sendInvoiceEmail, createShare: protectedProc .input( z.object({ diff --git a/faktorio-api/src/sendEmail.ts b/faktorio-api/src/sendEmail.ts index 6fcd54c3..509dca50 100755 --- a/faktorio-api/src/sendEmail.ts +++ b/faktorio-api/src/sendEmail.ts @@ -1,11 +1,15 @@ import type { SendEmail } from '@cloudflare/workers-types' +export type EmailData = { + to: { email: string; name: string } + subject: string + html: string + text?: string + replyTo?: { email: string; name: string } +} + export async function sendEmail( - emailData: { - to: { email: string; name: string } - subject: string - html: string - }, + emailData: EmailData, env: { SEND_EMAIL?: SendEmail } ): Promise { if (!env.SEND_EMAIL) { @@ -16,7 +20,9 @@ export async function sendEmail( from: { email: 'no-reply@faktorio.cz', name: 'Faktorio' }, to: emailData.to, subject: emailData.subject, - html: emailData.html + html: emailData.html, + ...(emailData.text ? { text: emailData.text } : {}), + ...(emailData.replyTo ? { replyTo: emailData.replyTo } : {}) }) console.log('Email accepted by Cloudflare Email Service:', messageId) diff --git a/faktorio-api/src/trpcContext.ts b/faktorio-api/src/trpcContext.ts index 57bc142e..d67f8b13 100644 --- a/faktorio-api/src/trpcContext.ts +++ b/faktorio-api/src/trpcContext.ts @@ -5,6 +5,7 @@ import superjson from 'superjson' import { UserSelectType } from 'faktorio-db/schema' import { Env } from './envSchema' +import type { EmailData } from './sendEmail' // JWT secret should be the same as in authRouter @@ -15,11 +16,7 @@ export type TrpcContext = { user: UserSelectType | undefined req: Request generateToken: (user: UserSelectType) => Promise - sendEmail: (email: { - to: { email: string; name: string } - subject: string - html: string - }) => Promise + sendEmail: (email: EmailData) => Promise } // @ts-ignore const isBrowser = typeof window !== 'undefined' diff --git a/faktorio-api/wrangler.toml b/faktorio-api/wrangler.toml index 1d4da76c..dee482ef 100644 --- a/faktorio-api/wrangler.toml +++ b/faktorio-api/wrangler.toml @@ -28,6 +28,7 @@ allowed_sender_addresses = ["no-reply@faktorio.cz"] # Note: Use secrets to store sensitive data. # Docs: https://developers.cloudflare.com/workers/platform/environment-variables [vars] +PUBLIC_APP_URL = "https://faktorio.cz" INVOICE_LOGO_PUBLIC_BASE_URL = "https://uploads.faktorio.cz" diff --git a/faktorio-fe/src/components/FeatureComparison.tsx b/faktorio-fe/src/components/FeatureComparison.tsx index de76bfa6..04a03474 100644 --- a/faktorio-fe/src/components/FeatureComparison.tsx +++ b/faktorio-fe/src/components/FeatureComparison.tsx @@ -61,7 +61,7 @@ const features = [ }, { name: 'Odesílání faktur emailem z aplikace', - faktorio: false, + faktorio: true, fakturovac: true, fakturoid: true, idoklad: true, diff --git a/faktorio-fe/src/pages/InvoiceDetail/InvoiceDetailPage.tsx b/faktorio-fe/src/pages/InvoiceDetail/InvoiceDetailPage.tsx index a289c002..1489f6c4 100644 --- a/faktorio-fe/src/pages/InvoiceDetail/InvoiceDetailPage.tsx +++ b/faktorio-fe/src/pages/InvoiceDetail/InvoiceDetailPage.tsx @@ -2,6 +2,7 @@ import { PDFDownloadLink } from '@react-pdf/renderer' import { CzechInvoicePDF } from './CzechInvoicePDF' import { InvoicePdfPreview } from './InvoicePdfPreview' +import { InvoiceEmailControls } from './InvoiceEmailControls' import { Button } from '@/components/ui/button' import { snakeCase } from 'lodash-es' import { useLocation, useParams, useSearchParams } from 'wouter' @@ -273,6 +274,12 @@ export const InvoiceDetail = ({ + {!isLocalUser && ( +
+ +
+ )} +
(null) + const utils = trpcClient.useUtils() + const draft = trpcClient.invoices.getEmailDraft.useQuery( + { invoiceId: invoice.id, kind: kind ?? 'invoice' }, + { enabled: kind !== null, staleTime: 0 } + ) + const send = trpcClient.invoices.sendEmail.useMutation({ + onSuccess: () => { + toast.success('E-mail byl odeslán.') + setKind(null) + void utils.invoices.invalidate() + }, + onError: (error) => toast.error(error.message) + }) + + return ( +
+

Odeslání e-mailem

+

+ Pošlete klientovi fakturu nebo připomenutí platby s odkazem na zobrazení + a stažení PDF. +

+
+ + +
+ {invoice.sent_at && ( +

+ Poslední odeslání faktury: {invoice.sent_at} +

+ )} + {invoice.reminder_sent_at && ( +

+ Poslední upomínka: {invoice.reminder_sent_at} +

+ )} + { + if (!open && !send.isPending) setKind(null) + }} + > + + + + {kind === 'reminder' ? 'Odeslat upomínku' : 'Odeslat fakturu'} + + + Text je předvyplněn podle jazyka kontaktu. Příjemce, předmět i + zprávu můžete upravit. + + + {draft.isError ? ( +
+

{draft.error.message}

+ +
+ ) : !draft.data ? ( +

Načítání…

+ ) : ( + kind && ( + setKind(null)} + onSend={(fields) => + send.mutate({ invoiceId: invoice.id, kind, ...fields }) + } + /> + ) + )} +
+
+
+ ) +} + +export function InvoiceEmailForm({ + draft, + kind, + pending, + onCancel, + onSend +}: { + draft: RouterOutputs['invoices']['getEmailDraft'] + kind: InvoiceEmailKind + pending: boolean + onCancel: () => void + onSend: (fields: EmailFields) => void +}) { + const [language, setLanguage] = useState(draft.language) + const [to, setTo] = useState(draft.to) + const [message, setMessage] = useState(() => + invoiceEmailTemplate(draft, draft.language, kind) + ) + const changeLanguage = (next: InvoiceEmailLanguage) => { + const previous = invoiceEmailTemplate(draft, language, kind) + if ( + (message.subject !== previous.subject || + message.body !== previous.body) && + !window.confirm( + 'Změna jazyka nahradí upravený předmět a text e-mailu novou šablonou. Pokračovat?' + ) + ) + return + setLanguage(next) + setMessage(invoiceEmailTemplate(draft, next, kind)) + } + const submit = (event: FormEvent) => { + event.preventDefault() + if (!pending) onSend({ to: to.trim(), language, ...message }) + } + return ( +
+
+
+ + setTo(e.target.value)} + /> +
+
+ + +

+ Změní pouze tento e-mail, nikoli jazyk kontaktu nebo faktury. +

+
+
+ + + setMessage({ ...message, subject: e.target.value }) + } + /> +
+
+ +