├── notification_center ├── src │ ├── vite-env.d.ts │ ├── lib │ │ └── utils.ts │ ├── main.tsx │ ├── components │ │ └── ui │ │ │ └── sonner.tsx │ ├── test │ │ ├── setup.ts │ │ ├── utils.tsx │ │ └── mocks │ │ │ └── api.ts │ └── api │ │ └── client.ts ├── public │ └── config.js ├── .gitignore ├── vite.config.ts ├── components.json ├── vitest.config.ts ├── tsconfig.json ├── eslint.config.js └── package.json ├── console ├── src │ ├── components │ │ ├── common │ │ │ ├── index.ts │ │ │ └── subtitle.tsx │ │ ├── timeline │ │ │ └── index.ts │ │ ├── blog_editor │ │ │ ├── menus │ │ │ │ ├── block-actions │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── RemovalActionGroup.tsx │ │ │ │ │ ├── block-actions-types.ts │ │ │ │ │ ├── useBlockPositioning.ts │ │ │ │ │ ├── PrimaryActionsGroup.tsx │ │ │ │ │ ├── useBlockTransformations.ts │ │ │ │ │ ├── ActionButton.tsx │ │ │ │ │ ├── TransformOptionsGroup.tsx │ │ │ │ │ └── block-actions.css │ │ │ │ └── suggestion │ │ │ │ │ ├── configs │ │ │ │ │ ├── index.ts │ │ │ │ │ └── emoji-config.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── suggestion-menu-core.css │ │ │ │ │ └── utils.ts │ │ │ ├── presets │ │ │ │ └── index.ts │ │ │ ├── components │ │ │ │ ├── colors │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── ColorConstants.ts │ │ │ │ │ └── ColorPatch.tsx │ │ │ │ └── InsertBlockButton.tsx │ │ │ ├── toolbars │ │ │ │ ├── index.ts │ │ │ │ ├── components │ │ │ │ │ └── index.ts │ │ │ │ ├── ToolbarSection.tsx │ │ │ │ ├── config.ts │ │ │ │ └── SelectionToolbar.tsx │ │ │ ├── extensions │ │ │ │ ├── index.ts │ │ │ │ ├── HorizontalRuleExtension.ts │ │ │ │ └── code-block-node.css │ │ │ ├── hooks │ │ │ │ ├── useNodeSelection.ts │ │ │ │ ├── useEditorStyles.ts │ │ │ │ ├── useCodeBlock.ts │ │ │ │ ├── useBlockquote.ts │ │ │ │ ├── useEditor.ts │ │ │ │ ├── useText.ts │ │ │ │ ├── useYoutube.ts │ │ │ │ ├── useTextAlign.ts │ │ │ │ └── useImage.ts │ │ │ ├── core │ │ │ │ ├── registry │ │ │ │ │ └── action-specs │ │ │ │ │ │ ├── image-action.ts │ │ │ │ │ │ ├── youtube-action.ts │ │ │ │ │ │ └── index.ts │ │ │ │ └── state │ │ │ │ │ └── useControls.ts │ │ │ ├── index.ts │ │ │ └── ui │ │ │ │ └── ShortcutBadge.tsx │ │ ├── templates │ │ │ ├── index.ts │ │ │ └── TemplateSelector.tsx │ │ ├── analytics │ │ │ └── index.ts │ │ ├── email_builder │ │ │ ├── panels │ │ │ │ └── PanelLayout.tsx │ │ │ ├── ui │ │ │ │ ├── tiptap │ │ │ │ │ ├── TiptapComponent.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── InputLayout.tsx │ │ │ │ ├── WidthPxInput.tsx │ │ │ │ ├── BorderRadiusInput.tsx │ │ │ │ └── AlignSelector.tsx │ │ │ └── blocks │ │ │ │ ├── index.ts │ │ │ │ ├── MjHeadBlock.tsx │ │ │ │ └── MjAttributesBlock.tsx │ │ ├── settings │ │ │ └── SettingsSectionHeader.tsx │ │ ├── contacts │ │ │ ├── fieldTypes.ts │ │ │ ├── ImportContactsButton.tsx │ │ │ └── DeleteContactModal.tsx │ │ ├── segment │ │ │ ├── operator_set_not_set.tsx │ │ │ ├── table_tag.tsx │ │ │ ├── operator_number.tsx │ │ │ ├── messages.ts │ │ │ ├── operator_array.tsx │ │ │ ├── input.module.css │ │ │ └── type_string.tsx │ │ ├── blog │ │ │ ├── PostStatusTag.tsx │ │ │ ├── DeletePostModal.tsx │ │ │ └── DeleteCategoryModal.tsx │ │ ├── filters │ │ │ ├── types.ts │ │ │ └── FilterInputs.tsx │ │ ├── file_manager │ │ │ └── interfaces.ts │ │ └── lists │ │ │ └── ImportContactsToListButton.tsx │ ├── assets │ │ ├── icon.png │ │ ├── logo.png │ │ └── s3-providers │ │ │ ├── aws-s3.png │ │ │ ├── linode.png │ │ │ ├── minio.png │ │ │ ├── wasabi.png │ │ │ ├── backblaze.png │ │ │ ├── hetzner.png │ │ │ ├── scaleway.png │ │ │ ├── cloudflare.png │ │ │ ├── digitalocean.png │ │ │ ├── google-cloud.png │ │ │ └── other.svg │ ├── main.tsx │ ├── constants │ │ └── routes.ts │ ├── __tests__ │ │ ├── App.test.tsx │ │ └── setup.tsx │ ├── vite-env.d.ts │ ├── lib │ │ ├── countries_timezones.ts │ │ ├── tld.ts │ │ ├── dayjs.ts │ │ └── timezones.ts │ ├── App.css │ ├── pages │ │ ├── LogoutPage.tsx │ │ └── LogsPage.tsx │ ├── services │ │ └── api │ │ │ ├── email.ts │ │ │ ├── setup.ts │ │ │ ├── auth.ts │ │ │ ├── webhook_registration.ts │ │ │ └── notification_center.ts │ ├── utils │ │ └── analytics-config.ts │ ├── types │ │ └── setup.ts │ ├── layouts │ │ └── MainLayout.tsx │ └── hooks │ │ └── useCustomFieldLabel.ts ├── public │ ├── icon.png │ ├── logo.png │ ├── splash.jpg │ ├── favicon.ico │ ├── mailgun.png │ ├── mailjet.png │ ├── postmark.png │ ├── supabase.png │ ├── amazonses.png │ ├── logo-white.png │ ├── sparkpost.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── apple-touch-icon.png │ ├── mstile-150x150.png │ ├── android-chrome-192x192.png │ ├── android-chrome-384x384.png │ ├── browserconfig.xml │ ├── site.webmanifest │ └── safari-pinned-tab.svg ├── tsconfig.json ├── .gitignore ├── .claude │ ├── settings.json │ └── settings.local.json ├── vitest.config.ts ├── tsconfig.node.json ├── tsconfig.app.json ├── eslint.config.js ├── playwright.config.ts ├── certificates │ ├── cert.pem │ └── key.pem └── vite.config.ts ├── openapi └── components │ ├── security.yaml │ └── schemas │ └── common.yaml ├── samples └── gcloud_cors.json ├── testdata └── certs │ └── .gitignore ├── tests ├── testdata │ └── certs │ │ ├── .gitignore │ │ ├── README.md │ │ ├── test_cert.pem │ │ └── test_key.pem └── compose.test.yaml ├── pkg ├── smtp │ └── auth.go ├── smtp_relay │ ├── auth.go │ └── tls.go ├── disposable_emails │ └── disposable_emails_test.go ├── liquid │ └── blog_renderer.go ├── templates │ └── supabase.go └── notifuse_mjml │ └── model_additional_test.go ├── .claude ├── hooks │ ├── go-format.sh │ └── ts-lint.sh └── settings.json ├── .gitignore ├── internal ├── repository │ ├── testutil │ │ ├── db.go │ │ └── db_test.go │ └── auth_repository.go ├── service │ ├── contact_timeline_service.go │ └── broadcast │ │ ├── time_provider.go │ │ └── config_test.go ├── http │ ├── utils.go │ ├── middleware │ │ └── cors.go │ └── templates │ │ ├── error_404.html │ │ └── error_workspace.html ├── domain │ ├── webhook_provider.go │ ├── contact_segment_queue.go │ ├── auth.go │ ├── setting.go │ ├── mocks │ │ ├── mock_http_client.go │ │ └── mock_email_provider_service.go │ ├── notification_center.go │ └── types.go └── migrations │ ├── interfaces.go │ ├── version.go │ └── v5.go ├── .cursor └── environment.json ├── codecov.yml ├── telemetry ├── function.yaml ├── test_payload.json ├── .gcloudignore └── deploy.sh ├── cmd └── hmac │ └── main.go ├── TODO.md ├── .air.toml ├── .cursorrules_agent ├── certs └── localhost.pem └── scripts └── run-integration-tests.sh /notification_center/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /console/src/components/common/index.ts: -------------------------------------------------------------------------------- 1 | export { ImageURLInput } from './ImageURLInput' 2 | 3 | -------------------------------------------------------------------------------- /console/src/components/timeline/index.ts: -------------------------------------------------------------------------------- 1 | export { ContactTimeline } from './ContactTimeline' 2 | -------------------------------------------------------------------------------- /notification_center/public/config.js: -------------------------------------------------------------------------------- 1 | window.API_ENDPOINT = 'https://localapi.notifuse.com:4000' -------------------------------------------------------------------------------- /console/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/public/icon.png -------------------------------------------------------------------------------- /console/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/public/logo.png -------------------------------------------------------------------------------- /console/public/splash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/public/splash.jpg -------------------------------------------------------------------------------- /console/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/public/favicon.ico -------------------------------------------------------------------------------- /console/public/mailgun.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/public/mailgun.png -------------------------------------------------------------------------------- /console/public/mailjet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/public/mailjet.png -------------------------------------------------------------------------------- /console/public/postmark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/public/postmark.png -------------------------------------------------------------------------------- /console/public/supabase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/public/supabase.png -------------------------------------------------------------------------------- /console/src/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/src/assets/icon.png -------------------------------------------------------------------------------- /console/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/src/assets/logo.png -------------------------------------------------------------------------------- /console/public/amazonses.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/public/amazonses.png -------------------------------------------------------------------------------- /console/public/logo-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/public/logo-white.png -------------------------------------------------------------------------------- /console/public/sparkpost.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/public/sparkpost.png -------------------------------------------------------------------------------- /console/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/public/favicon-16x16.png -------------------------------------------------------------------------------- /console/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/public/favicon-32x32.png -------------------------------------------------------------------------------- /console/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/public/apple-touch-icon.png -------------------------------------------------------------------------------- /console/public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/public/mstile-150x150.png -------------------------------------------------------------------------------- /console/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /console/public/android-chrome-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/public/android-chrome-384x384.png -------------------------------------------------------------------------------- /console/src/assets/s3-providers/aws-s3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/src/assets/s3-providers/aws-s3.png -------------------------------------------------------------------------------- /console/src/assets/s3-providers/linode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/src/assets/s3-providers/linode.png -------------------------------------------------------------------------------- /console/src/assets/s3-providers/minio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/src/assets/s3-providers/minio.png -------------------------------------------------------------------------------- /console/src/assets/s3-providers/wasabi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/src/assets/s3-providers/wasabi.png -------------------------------------------------------------------------------- /openapi/components/security.yaml: -------------------------------------------------------------------------------- 1 | BearerAuth: 2 | type: http 3 | scheme: bearer 4 | description: API token for authentication 5 | -------------------------------------------------------------------------------- /console/src/assets/s3-providers/backblaze.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/src/assets/s3-providers/backblaze.png -------------------------------------------------------------------------------- /console/src/assets/s3-providers/hetzner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/src/assets/s3-providers/hetzner.png -------------------------------------------------------------------------------- /console/src/assets/s3-providers/scaleway.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/src/assets/s3-providers/scaleway.png -------------------------------------------------------------------------------- /console/src/assets/s3-providers/cloudflare.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/src/assets/s3-providers/cloudflare.png -------------------------------------------------------------------------------- /console/src/assets/s3-providers/digitalocean.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/src/assets/s3-providers/digitalocean.png -------------------------------------------------------------------------------- /console/src/assets/s3-providers/google-cloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Notifuse/notifuse/HEAD/console/src/assets/s3-providers/google-cloud.png -------------------------------------------------------------------------------- /samples/gcloud_cors.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "origin": ["*"], 4 | "method": ["GET", "POST", "HEAD", "PUT", "DELETE", "OPTIONS"], 5 | "responseHeader": ["Content-Type", "*"] 6 | } 7 | ] 8 | -------------------------------------------------------------------------------- /openapi/components/schemas/common.yaml: -------------------------------------------------------------------------------- 1 | ErrorResponse: 2 | type: object 3 | properties: 4 | error: 5 | type: string 6 | description: Error message 7 | required: 8 | - error 9 | -------------------------------------------------------------------------------- /notification_center/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/menus/block-actions/index.ts: -------------------------------------------------------------------------------- 1 | export { BlockActionsMenu } from './BlockActionsMenu' 2 | export type { BlockActionsConfig, BlockPositionData, ActionItemConfig } from './block-actions-types' 3 | 4 | -------------------------------------------------------------------------------- /console/src/main.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom/client' 2 | import App from './App' 3 | import 'antd/dist/reset.css' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render() 7 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/menus/suggestion/configs/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Suggestion menu configurations 3 | * Export all available suggestion configs 4 | */ 5 | 6 | export { emojiConfig } from './emoji-config' 7 | export { slashConfig } from './slash-config' 8 | 9 | -------------------------------------------------------------------------------- /console/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ], 7 | "compilerOptions": { 8 | "baseUrl": ".", 9 | "paths": { 10 | "@/*": ["src/*"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /console/src/constants/routes.ts: -------------------------------------------------------------------------------- 1 | export const PUBLIC_ROUTES = ['/signin', '/accept-invitation', '/logout'] as const 2 | export type PublicRoute = (typeof PUBLIC_ROUTES)[number] 3 | 4 | export const isPublicRoute = (path: string): boolean => { 5 | return PUBLIC_ROUTES.includes(path as PublicRoute) 6 | } 7 | -------------------------------------------------------------------------------- /notification_center/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /testdata/certs/.gitignore: -------------------------------------------------------------------------------- 1 | # Keep the directory structure but ignore certificate files in case they're regenerated 2 | # The actual test certificates are committed to the repo for convenience 3 | # but we ignore any locally generated ones 4 | 5 | *.pem.bak 6 | *.key.bak 7 | *.csr 8 | *.old 9 | 10 | -------------------------------------------------------------------------------- /tests/testdata/certs/.gitignore: -------------------------------------------------------------------------------- 1 | # Keep the directory structure but ignore certificate files in case they're regenerated 2 | # The actual test certificates are committed to the repo for convenience 3 | # but we ignore any locally generated ones 4 | 5 | *.pem.bak 6 | *.key.bak 7 | *.csr 8 | *.old 9 | 10 | -------------------------------------------------------------------------------- /console/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /console/src/components/templates/index.ts: -------------------------------------------------------------------------------- 1 | export { default as TemplatePreviewPopover } from './TemplatePreviewDrawer' 2 | export { default as TemplateSelector } from './TemplateSelector' 3 | export { default as TemplateSelectorInput } from './TemplateSelectorInput' 4 | export { renderCategoryTag } from './CreateTemplateDrawer' 5 | -------------------------------------------------------------------------------- /console/src/__tests__/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { render } from '@testing-library/react' 3 | import { App } from '../App' 4 | 5 | describe('App', () => { 6 | it('renders without crashing', () => { 7 | render() 8 | expect(document.body).toBeDefined() 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /console/src/components/analytics/index.ts: -------------------------------------------------------------------------------- 1 | export { AnalyticsDashboard } from './AnalyticsDashboard' 2 | export { ChartVisualization } from './ChartVisualization' 3 | export { EmailMetricsChart } from './EmailMetricsChart' 4 | export { NewContactsTable } from './NewContactsTable' 5 | export { FailedMessagesTable } from './FailedMessagesTable' 6 | -------------------------------------------------------------------------------- /pkg/smtp/auth.go: -------------------------------------------------------------------------------- 1 | package smtp 2 | 3 | // AuthHandler is a function type that authenticates SMTP credentials 4 | // It receives the username (workspace_id) and password (api_key) 5 | // Returns the workspace_id if authentication succeeds, or an error if it fails 6 | type AuthHandler func(username, password string) (workspaceID string, err error) 7 | -------------------------------------------------------------------------------- /pkg/smtp_relay/auth.go: -------------------------------------------------------------------------------- 1 | package smtp_relay 2 | 3 | // AuthHandler is a function type that authenticates SMTP credentials 4 | // It receives the username (workspace_id) and password (api_key) 5 | // Returns the workspace_id if authentication succeeds, or an error if it fails 6 | type AuthHandler func(username, password string) (workspaceID string, err error) 7 | -------------------------------------------------------------------------------- /console/src/assets/s3-providers/other.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/presets/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Notifuse Editor Style Presets 3 | * 4 | * Collection of pre-configured style presets for different use cases 5 | */ 6 | 7 | export { academicPaperPreset } from './academicPaper' 8 | 9 | // Re-export types for convenience 10 | export type { EditorStyleConfig } from '../types/EditorStyleConfig' 11 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/menus/suggestion/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Suggestion menu system 3 | * Specific menu components for emoji and slash commands 4 | */ 5 | 6 | export { EmojiMenu } from './EmojiMenu' 7 | export { SlashMenu } from './SlashMenu' 8 | export { SuggestionMenuItem } from './SuggestionMenuItem' 9 | export * from './types' 10 | export * from './configs' 11 | -------------------------------------------------------------------------------- /notification_center/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /console/src/components/email_builder/panels/PanelLayout.tsx: -------------------------------------------------------------------------------- 1 | const PanelLayout: React.FC<{ title: string; children: React.ReactNode }> = ({ 2 | title, 3 | children 4 | }) => { 5 | return ( 6 | <> 7 |
{title}
8 |
{children}
9 | 10 | ) 11 | } 12 | 13 | export default PanelLayout 14 | -------------------------------------------------------------------------------- /console/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare global { 4 | interface Window { 5 | API_ENDPOINT: string 6 | IS_INSTALLED: boolean 7 | VERSION: string 8 | ROOT_EMAIL: string 9 | SMTP_RELAY_ENABLED: boolean 10 | SMTP_RELAY_DOMAIN: string 11 | SMTP_RELAY_PORT: number 12 | SMTP_RELAY_TLS_ENABLED: boolean 13 | } 14 | } 15 | 16 | export {} 17 | -------------------------------------------------------------------------------- /console/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Playwright 27 | playwright-report/ 28 | playwright/.cache/ 29 | test-results/ 30 | -------------------------------------------------------------------------------- /.claude/hooks/go-format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Extract the file path from hook input 5 | FILE=$(jq -r '.files[0].path // empty' 2>/dev/null || echo "") 6 | 7 | # Only process Go files 8 | if [[ ! "$FILE" =~ \.go$ || ! -f "$FILE" ]]; then 9 | exit 0 10 | fi 11 | 12 | echo "Formatting $FILE with gofmt..." 13 | gofmt -w "$FILE" 14 | 15 | echo "Running go vet..." 16 | PACKAGE_DIR=$(dirname "$FILE") 17 | cd "$PACKAGE_DIR" 18 | go vet ./... 19 | 20 | exit 0 21 | -------------------------------------------------------------------------------- /notification_center/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import tailwindcss from '@tailwindcss/vite' 4 | import path from 'path' 5 | // https://vite.dev/config/ 6 | export default defineConfig({ 7 | plugins: [tailwindcss(), react()], 8 | server: { 9 | port: 3001, 10 | strictPort: true 11 | }, 12 | resolve: { 13 | alias: { 14 | '@': path.resolve(__dirname, './src') 15 | } 16 | } 17 | }) 18 | -------------------------------------------------------------------------------- /console/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Notifuse", 3 | "short_name": "Notifuse", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-384x384.png", 12 | "sizes": "384x384", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/components/colors/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Shared color components and constants 3 | * Used across toolbar and block actions menu 4 | */ 5 | 6 | export { ColorPatch } from './ColorPatch' 7 | export type { ColorPatchProps } from './ColorPatch' 8 | 9 | export { ColorGrid } from './ColorGrid' 10 | export type { ColorGridProps } from './ColorGrid' 11 | 12 | export { TEXT_COLORS, BACKGROUND_COLORS } from './ColorConstants' 13 | export type { ColorOption } from './ColorConstants' 14 | -------------------------------------------------------------------------------- /.claude/hooks/ts-lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Extract the file path from hook input 5 | FILE=$(jq -r '.files[0].path // empty' 2>/dev/null || echo "") 6 | 7 | # Only process TypeScript/React files in console directory 8 | if [[ ! "$FILE" =~ console/.*\.(ts|tsx)$ || ! -f "$FILE" ]]; then 9 | exit 0 10 | fi 11 | 12 | echo "Linting $FILE with ESLint..." 13 | 14 | # Run ESLint from console directory on the specific file 15 | cd console 16 | npx eslint "../$FILE" --max-warnings 0 17 | 18 | exit 0 19 | -------------------------------------------------------------------------------- /console/src/components/settings/SettingsSectionHeader.tsx: -------------------------------------------------------------------------------- 1 | import { Divider } from 'antd' 2 | 3 | interface SettingsSectionHeaderProps { 4 | title: string 5 | description: string 6 | } 7 | 8 | export function SettingsSectionHeader({ title, description }: SettingsSectionHeaderProps) { 9 | return ( 10 | <> 11 |
{title}
12 |
{description}
13 | 14 | 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/toolbars/index.ts: -------------------------------------------------------------------------------- 1 | export { FloatingToolbar } from './FloatingToolbar' 2 | export { ToolbarButton } from './ToolbarButton' 3 | export { ToolbarSection } from './ToolbarSection' 4 | export { SelectionToolbar } from './SelectionToolbar' 5 | 6 | export type { FloatingToolbarProps } from './FloatingToolbar' 7 | export type { ToolbarButtonProps } from './ToolbarButton' 8 | export type { ToolbarSectionProps } from './ToolbarSection' 9 | 10 | // Re-export components 11 | export * from './components' 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .env.test 3 | 4 | *.log 5 | node_modules 6 | pkg/liquid/liquid.bundle.js 7 | .DS_Store 8 | server/tmp/ 9 | tmp/ 10 | 11 | bin/ 12 | coverage.html 13 | coverage.out 14 | coverage.txt 15 | coverage-unit.txt 16 | coverage-integration.txt 17 | /api 18 | console/public/config.js 19 | 20 | notification_center/tsconfig.tsbuildinfo 21 | dev-certs/ 22 | 23 | coverage-internal-pkg.out 24 | 25 | coverage-report.txt 26 | 27 | .claude/settings.local.json 28 | 29 | console/.claude/settings.local.json 30 | 31 | service.test 32 | -------------------------------------------------------------------------------- /notification_center/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/index.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /notification_center/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | import react from '@vitejs/plugin-react' 3 | import tailwindcss from '@tailwindcss/vite' 4 | import path from 'path' 5 | 6 | export default defineConfig({ 7 | plugins: [tailwindcss(), react()], 8 | test: { 9 | globals: true, 10 | environment: 'happy-dom', 11 | setupFiles: './src/test/setup.ts', 12 | css: true, 13 | }, 14 | resolve: { 15 | alias: { 16 | '@': path.resolve(__dirname, './src') 17 | } 18 | } 19 | }) 20 | 21 | -------------------------------------------------------------------------------- /pkg/disposable_emails/disposable_emails_test.go: -------------------------------------------------------------------------------- 1 | package disposable_emails 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestIsDisposableEmail(t *testing.T) { 8 | tests := []struct { 9 | email string 10 | want bool 11 | }{ 12 | {email: "test@example.com", want: false}, 13 | {email: "0-180.com", want: true}, 14 | } 15 | 16 | for _, test := range tests { 17 | if got := IsDisposableEmail(test.email); got != test.want { 18 | t.Errorf("IsDisposableEmail(%q) = %v, want %v", test.email, got, test.want) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.claude/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "PostToolUse": [ 4 | { 5 | "matcher": "Write|Edit", 6 | "hooks": [ 7 | { 8 | "type": "command", 9 | "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/go-format.sh", 10 | "timeout": 30 11 | }, 12 | { 13 | "type": "command", 14 | "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ts-lint.sh", 15 | "timeout": 30 16 | } 17 | ] 18 | } 19 | ] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/extensions/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Notifuse Editor Extensions 3 | */ 4 | 5 | export { BackgroundExtension } from './BackgroundExtension' 6 | export type { BackgroundConfig } from './BackgroundExtension' 7 | 8 | export { AlignmentExtension } from './AlignmentExtension' 9 | export type { AlignmentConfig } from './AlignmentExtension' 10 | 11 | export { HorizontalRuleExtension } from './HorizontalRuleExtension' 12 | 13 | export { ImageExtension } from './ImageExtension' 14 | 15 | export { YoutubeExtension } from './YoutubeExtension' 16 | -------------------------------------------------------------------------------- /notification_center/src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster as Sonner, type ToasterProps } from 'sonner' 2 | 3 | const Toaster = ({ ...props }: ToasterProps) => { 4 | return ( 5 | 17 | ) 18 | } 19 | 20 | export { Toaster } 21 | -------------------------------------------------------------------------------- /internal/repository/testutil/db.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "database/sql" 5 | "testing" 6 | 7 | "github.com/DATA-DOG/go-sqlmock" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | // SetupMockDB creates a mock database connection for testing 12 | func SetupMockDB(t *testing.T) (*sql.DB, sqlmock.Sqlmock, func()) { 13 | db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) 14 | require.NoError(t, err, "Failed to create mock database") 15 | 16 | cleanup := func() { 17 | _ = db.Close() 18 | } 19 | 20 | return db, mock, cleanup 21 | } 22 | -------------------------------------------------------------------------------- /.cursor/environment.json: -------------------------------------------------------------------------------- 1 | { 2 | "install": "curl -fsSL https://get.docker.com -o get-docker.sh && sudo sh get-docker.sh && sudo usermod -aG docker $USER && rm get-docker.sh && sudo mkdir -p /etc/docker && echo '{\"storage-driver\":\"vfs\",\"iptables\":false,\"ip-forward\":false,\"ip-masq\":false,\"userland-proxy\":false,\"bridge\":\"none\"}' | sudo tee /etc/docker/daemon.json > /dev/null", 3 | "start": "sudo service docker start && sleep 3 && sudo chmod 666 /var/run/docker.sock && until docker info > /dev/null 2>&1; do sleep 1; done && echo 'Docker is ready' && docker --version && docker compose version" 4 | } 5 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 70% 6 | threshold: 10% 7 | patch: off 8 | range: '60...80' 9 | # Exclude mock files from coverage calculation 10 | ignore: 11 | - '**/mocks/**' 12 | - '**/mock/**' 13 | - '**/*_mock.go' 14 | - '**/*mock*.go' 15 | - '**/database/utils.go' 16 | - '**/cmd/api/**' 17 | - 'cmd/api/**' 18 | - '**/pkg/tracing/**' 19 | - '**/pkg/logger/testing.go' 20 | - '**/http/middleware/tracing.go' 21 | - '**/internal/tracing/**' 22 | - '**/tests/**' 23 | - '**/tests/**' 24 | - '**/testutil/**' 25 | -------------------------------------------------------------------------------- /console/src/components/contacts/fieldTypes.ts: -------------------------------------------------------------------------------- 1 | export type FieldType = 'string' | 'number' | 'datetime' | 'json' | 'timezone' | 'language' | 'country' 2 | 3 | // Determine field type from field key 4 | export const getFieldType = (fieldKey: string): FieldType => { 5 | if (fieldKey.startsWith('custom_number_')) return 'number' 6 | if (fieldKey.startsWith('custom_datetime_')) return 'datetime' 7 | if (fieldKey.startsWith('custom_json_')) return 'json' 8 | if (fieldKey === 'timezone') return 'timezone' 9 | if (fieldKey === 'language') return 'language' 10 | if (fieldKey === 'country') return 'country' 11 | return 'string' 12 | } 13 | -------------------------------------------------------------------------------- /telemetry/function.yaml: -------------------------------------------------------------------------------- 1 | # Google Cloud Function configuration 2 | # This file can be used with Infrastructure as Code tools like Terraform 3 | 4 | name: notifuse-telemetry 5 | description: 'Receives anonymous telemetry data from Notifuse platform and logs it to Google Cloud Logging' 6 | runtime: go124 7 | entry_point: ReceiveTelemetry 8 | trigger: 9 | type: http 10 | allow_unauthenticated: true 11 | resources: 12 | memory: 256MB 13 | timeout: 30s 14 | max_instances: 10 15 | environment_variables: 16 | GCP_PROJECT: '${PROJECT_ID}' 17 | labels: 18 | application: notifuse 19 | component: telemetry 20 | environment: production 21 | -------------------------------------------------------------------------------- /pkg/liquid/blog_renderer.go: -------------------------------------------------------------------------------- 1 | package liquid 2 | 3 | // RenderBlogTemplate renders a Liquid template with the provided data using liquidgo. 4 | // This uses the Notifuse/liquidgo library with full Shopify-compatible render tag support. 5 | // 6 | // The partials parameter is optional - pass nil if no partials are needed. 7 | // Partials can be rendered in templates using: {% render 'partial_name' %} 8 | // or with parameters: {% render 'partial_name', param: value %} 9 | func RenderBlogTemplate(template string, data map[string]interface{}, partials map[string]string) (string, error) { 10 | return RenderBlogTemplateGo(template, data, partials) 11 | } 12 | -------------------------------------------------------------------------------- /cmd/hmac/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/hmac" 5 | "crypto/sha256" 6 | "fmt" 7 | "os" 8 | ) 9 | 10 | func main() { 11 | if len(os.Args) < 3 { 12 | fmt.Println("Usage: go run cmd/hmac/main.go ") 13 | os.Exit(1) 14 | } 15 | 16 | email := os.Args[1] 17 | secretKey := os.Args[2] 18 | 19 | h := hmac.New(sha256.New, []byte(secretKey)) 20 | h.Write([]byte(email)) 21 | result := fmt.Sprintf("%x", h.Sum(nil)) 22 | 23 | fmt.Println() 24 | fmt.Printf("Root Email: %s\n", email) 25 | fmt.Printf("HMAC: %s\n", result) 26 | fmt.Println() 27 | fmt.Printf("Reset URL: /api/demo.reset?hmac=%s\n", result) 28 | } 29 | -------------------------------------------------------------------------------- /console/src/lib/countries_timezones.ts: -------------------------------------------------------------------------------- 1 | import CountriesTimezonesData from './countries_timezones.json' 2 | import { map } from 'lodash' 3 | 4 | // convert to arrays 5 | type Country = { 6 | name: string 7 | abbr: string 8 | zones: string[] 9 | } 10 | 11 | export const CountriesMap: Record = CountriesTimezonesData.countries 12 | export const Countries = map(CountriesTimezonesData.countries, (x) => x) 13 | export const CountriesFormOptions = map(CountriesTimezonesData.countries, (x) => { 14 | return { 15 | value: x.abbr, 16 | label: x.abbr + ' - ' + x.name 17 | } 18 | }) 19 | 20 | export default CountriesTimezonesData 21 | -------------------------------------------------------------------------------- /console/.claude/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "PostToolUse": [ 4 | { 5 | "matcher": "Edit|Write", 6 | "hooks": [ 7 | { 8 | "type": "command", 9 | "command": "file_path=\"$CLAUDE_FILE_PATH\"\nif [[ \"$file_path\" == *.ts || \"$file_path\" == *.tsx ]]; then\n cd /Users/pierre/Sites/notifuse3/code/notifuse/console && npx eslint \"$file_path\" 2>&1\n cd /Users/pierre/Sites/notifuse3/code/notifuse/console && npx tsc --noEmit --project tsconfig.app.json 2>&1 | grep -E \"^src/\" | head -20\nfi", 10 | "timeout": 120 11 | } 12 | ] 13 | } 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/toolbars/components/index.ts: -------------------------------------------------------------------------------- 1 | export { TurnIntoDropdown } from './TurnIntoDropdown' 2 | export { LinkPopover } from './LinkPopover' 3 | export { ColorPicker } from './ColorPicker' 4 | export { MoreMenu } from './MoreMenu' 5 | export { CodeBlockToolbar } from './CodeBlockToolbar' 6 | export { useRecentColors } from './useRecentColors' 7 | 8 | export type { TurnIntoDropdownProps } from './TurnIntoDropdown' 9 | export type { LinkPopoverProps } from './LinkPopover' 10 | export type { ColorPickerProps } from './ColorPicker' 11 | export type { MoreMenuProps } from './MoreMenu' 12 | export type { RecentColor } from './useRecentColors' 13 | -------------------------------------------------------------------------------- /telemetry/test_payload.json: -------------------------------------------------------------------------------- 1 | { 2 | "workspace_id_sha1": "a1b2c3d4e5f67890abcdef1234567890abcdef12", 3 | "workspace_created_at": "2023-01-15T10:30:00Z", 4 | "workspace_updated_at": "2025-08-15T14:22:30Z", 5 | "last_message_at": "2025-08-20T09:45:12Z", 6 | "contacts_count": 1250, 7 | "broadcasts_count": 18, 8 | "transactional_count": 87, 9 | "messages_count": 3420, 10 | "lists_count": 7, 11 | "segments_count": 14, 12 | "users_count": 5, 13 | "api_endpoint": "https://api.notifuse.com", 14 | "mailgun": true, 15 | "amazonses": true, 16 | "mailjet": true, 17 | "sparkpost": false, 18 | "postmark": false, 19 | "smtp": false, 20 | "s3": false 21 | } 22 | -------------------------------------------------------------------------------- /console/src/components/segment/operator_set_not_set.tsx: -------------------------------------------------------------------------------- 1 | import { IOperator, Operator } from '../../services/api/segment' 2 | 3 | export class OperatorSet implements IOperator { 4 | type: Operator = 'is_set' 5 | label = 'is set' 6 | 7 | render() { 8 | return {this.label} 9 | } 10 | 11 | renderFormItems() { 12 | return <> 13 | } 14 | } 15 | 16 | export class OperatorNotSet implements IOperator { 17 | type: Operator = 'is_not_set' 18 | label = 'is not set' 19 | 20 | render() { 21 | return {this.label} 22 | } 23 | 24 | renderFormItems() { 25 | return <> 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /console/vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vitest/config' 3 | import react from '@vitejs/plugin-react' 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | test: { 8 | environment: 'jsdom', 9 | globals: true, 10 | setupFiles: ['./src/__tests__/setup.tsx'], 11 | include: ['src/**/*.{test,spec}.{ts,tsx}'], 12 | coverage: { 13 | provider: 'v8', 14 | reporter: ['text', 'json', 'html'], 15 | include: ['src/**/*.{ts,tsx}'], 16 | exclude: ['src/**/*.{test,spec}.{ts,tsx}', 'src/vite-env.d.ts', 'src/__tests__/**/*'] 17 | } 18 | }, 19 | resolve: { 20 | alias: { 21 | '@': '/src' 22 | } 23 | } 24 | }) 25 | -------------------------------------------------------------------------------- /console/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "types": ["node"], 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true 23 | }, 24 | "include": ["vite.config.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /console/src/lib/tld.ts: -------------------------------------------------------------------------------- 1 | // Function to extract TLD from URL 2 | const extractTLD = (url: string): string => { 3 | try { 4 | if (!url || !url.trim()) return '' 5 | 6 | // Add protocol if missing to make URL parsing work 7 | const urlWithProtocol = url.startsWith('http') ? url : `https://${url}` 8 | const hostname = new URL(urlWithProtocol).hostname 9 | 10 | // Split by dots and get the last two parts (or just the last if it's a simple domain) 11 | const parts = hostname.split('.') 12 | if (parts.length >= 2) { 13 | return parts.slice(-2).join('.') 14 | } 15 | return hostname 16 | } catch (e) { 17 | console.error('Error extracting TLD:', e) 18 | return '' 19 | } 20 | } 21 | 22 | export default extractTLD 23 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | - docs: blog setup / theme / post 4 | - docs: custom events + segments for goals 5 | 6 | - create notifuse blog 7 | - pages on homepage for: 8 | - rich contact profiles with custom events + segments 9 | - newsletter campaigns 10 | - transactional API 11 | - blog posts 12 | 13 | ## Content 14 | 15 | - send newsletter to all contacts+previous customers 16 | - post supabase on twitter and facebook 17 | - page vs mailerlite 18 | - page vs mautic 19 | 20 | ## Eventual features 21 | 22 | - server settings panel for root user 23 | - better design for system email (use MJML for template) 24 | - add contact_list reason string 25 | 26 | ## Roadmap 27 | 28 | - check for updates + newsletter box 29 | - automations with async triggers 30 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/extensions/HorizontalRuleExtension.ts: -------------------------------------------------------------------------------- 1 | import { mergeAttributes } from '@tiptap/react' 2 | import TiptapHorizontalRule from '@tiptap/extension-horizontal-rule' 3 | 4 | /** 5 | * HorizontalRule Extension 6 | * 7 | * Extends the standard HorizontalRule extension to wrap the
element 8 | * in a
container with a data-type attribute for better styling control. 9 | * 10 | * HTML Output:

11 | */ 12 | export const HorizontalRuleExtension = TiptapHorizontalRule.extend({ 13 | name: 'horizontalRule', 14 | 15 | renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { 16 | return ['div', mergeAttributes(HTMLAttributes, { 'data-type': this.name }), ['hr']] 17 | } 18 | }) 19 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/menus/block-actions/RemovalActionGroup.tsx: -------------------------------------------------------------------------------- 1 | import type { MenuProps } from 'antd' 2 | import { useAction } from '../../core/registry/action-specs' 3 | import { createActionMenuItem } from './ActionButton' 4 | 5 | /** 6 | * RemovalActionGroup - Delete block action 7 | * Returns menu items configuration for Antd Menu 8 | * Now using the action registry for improved performance 9 | */ 10 | export function useRemovalActionGroup(): MenuProps['items'] { 11 | // Get delete action from registry 12 | const { execute, isAvailable, label, icon } = useAction('delete') 13 | 14 | return [ 15 | createActionMenuItem({ 16 | icon: icon!, 17 | label: label!, 18 | action: execute, 19 | disabled: !isAvailable 20 | }) 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/hooks/useNodeSelection.ts: -------------------------------------------------------------------------------- 1 | import type { Editor } from '@tiptap/react' 2 | import { NodeSelection } from '@tiptap/pm/state' 3 | 4 | export const HIDE_FLOATING_META = 'hideFloatingToolbar' 5 | 6 | /** 7 | * Programmatically select a node and hide floating toolbar for that selection 8 | * Used when clicking drag handle to prevent floating toolbar from appearing 9 | * @param editor - The Tiptap editor instance 10 | * @param pos - The position of the node to select 11 | */ 12 | export const selectNodeAndHideFloating = (editor: Editor, pos: number) => { 13 | if (!editor) return 14 | const { state, view } = editor 15 | view.dispatch( 16 | state.tr.setSelection(NodeSelection.create(state.doc, pos)).setMeta(HIDE_FLOATING_META, true) 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /console/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["src"] 26 | } 27 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/extensions/code-block-node.css: -------------------------------------------------------------------------------- 1 | /* Code Block Node Wrapper */ 2 | .code-block-node-wrapper { 3 | margin: 1rem 0; 4 | display: block; 5 | } 6 | 7 | /* Caption Wrapper and Input */ 8 | .code-block-caption-wrapper { 9 | margin-top: 8px; 10 | width: 100%; 11 | } 12 | 13 | .code-block-caption-input { 14 | text-align: center; 15 | font-style: italic; 16 | color: var(--editor-caption-color); 17 | font-size: var(--editor-caption-font-size); 18 | padding: 4px 8px; 19 | } 20 | 21 | .code-block-caption-input input { 22 | text-align: center !important; 23 | font-style: italic; 24 | color: var(--editor-caption-color) !important; 25 | } 26 | 27 | .code-block-caption-input input::placeholder { 28 | color: #9ca3af; 29 | font-style: italic; 30 | } 31 | 32 | -------------------------------------------------------------------------------- /console/src/lib/dayjs.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import timezone from 'dayjs/plugin/timezone' 3 | import utc from 'dayjs/plugin/utc' 4 | import relativeTime from 'dayjs/plugin/relativeTime' 5 | import localizedFormat from 'dayjs/plugin/localizedFormat' 6 | import customParseFormat from 'dayjs/plugin/customParseFormat' 7 | import isSameOrBefore from 'dayjs/plugin/isSameOrBefore' 8 | import isSameOrAfter from 'dayjs/plugin/isSameOrAfter' 9 | import isToday from 'dayjs/plugin/isToday' 10 | 11 | // Extend dayjs with plugins 12 | dayjs.extend(utc) 13 | dayjs.extend(timezone) 14 | dayjs.extend(relativeTime) 15 | dayjs.extend(localizedFormat) 16 | dayjs.extend(customParseFormat) 17 | dayjs.extend(isSameOrBefore) 18 | dayjs.extend(isSameOrAfter) 19 | dayjs.extend(isToday) 20 | 21 | export default dayjs 22 | -------------------------------------------------------------------------------- /console/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/core/registry/action-specs/image-action.ts: -------------------------------------------------------------------------------- 1 | import type { Editor } from '@tiptap/react' 2 | import { Image } from 'lucide-react' 3 | 4 | // Import Image hook functions 5 | import { canInsertImage, insertImage, isImageActive } from '../../../hooks/useImage' 6 | 7 | import type { ActionDefinition } from '../ActionRegistry' 8 | 9 | /** 10 | * Image insert action definition 11 | */ 12 | export const toImageAction: ActionDefinition = { 13 | id: 'to-image', 14 | type: 'transform', 15 | label: 'Image', 16 | icon: Image, 17 | shortcut: '', 18 | group: 'Media', 19 | checkAvailability: (editor: Editor | null) => canInsertImage(editor), 20 | checkActive: (editor: Editor | null) => isImageActive(editor), 21 | execute: (editor: Editor | null) => insertImage(editor) 22 | } 23 | -------------------------------------------------------------------------------- /console/.claude/settings.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": { 3 | "allow": [ 4 | "Bash(npx eslint:*)", 5 | "Bash(npx tsc:*)", 6 | "Bash(npm test:*)", 7 | "Bash(npx playwright test:*)", 8 | "Bash(go test:*)", 9 | "Bash(INTEGRATION_TESTS=true go test:*)", 10 | "Bash(make test-service:*)", 11 | "Bash(make test-http:*)", 12 | "Bash(make test-unit:*)", 13 | "Bash(npm run lint:*)", 14 | "Bash(make test-domain:*)", 15 | "Bash(make test-repo:*)", 16 | "Bash(go generate:*)", 17 | "Bash(go build:*)", 18 | "Bash(cat:*)", 19 | "Bash(go run:*)", 20 | "Bash(find:*)", 21 | "WebFetch(domain:github.com)" 22 | ], 23 | "deny": [], 24 | "ask": [] 25 | }, 26 | "model": "opus", 27 | "alwaysThinkingEnabled": true 28 | } 29 | -------------------------------------------------------------------------------- /console/src/pages/LogoutPage.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useAuth } from '../contexts/AuthContext' 3 | import { useNavigate } from '@tanstack/react-router' 4 | import { Spin } from 'antd' 5 | 6 | export function LogoutPage() { 7 | const { signout } = useAuth() 8 | const navigate = useNavigate() 9 | 10 | useEffect(() => { 11 | const performSignout = async () => { 12 | await signout() 13 | navigate({ to: '/console/signin' }) 14 | } 15 | performSignout() 16 | }, [signout, navigate]) 17 | 18 | return ( 19 |
27 | 28 |
29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/menus/block-actions/block-actions-types.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from '@tiptap/pm/model' 2 | import type { Editor } from '@tiptap/react' 3 | 4 | /** 5 | * Configuration for Block Actions Menu 6 | */ 7 | export interface BlockActionsConfig { 8 | showSlashTrigger?: boolean 9 | } 10 | 11 | /** 12 | * Data about a block's position in the document 13 | */ 14 | export interface BlockPositionData { 15 | node: Node | null 16 | editor: Editor 17 | pos: number 18 | } 19 | 20 | /** 21 | * Configuration for a single action item in the menu 22 | */ 23 | export interface ActionItemConfig { 24 | icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }> 25 | label: string 26 | action: () => void 27 | disabled?: boolean 28 | active?: boolean 29 | shortcut?: React.ReactNode 30 | } 31 | -------------------------------------------------------------------------------- /console/src/components/common/subtitle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const Subtitle = ({ 4 | className, 5 | children, 6 | borderBottom = false, 7 | primary 8 | }: { 9 | className?: string 10 | children: React.ReactNode 11 | borderBottom?: boolean 12 | primary?: boolean 13 | }) => { 14 | // Detect if primary is set; true if defined (not undefined), false if not 15 | const isPrimary = typeof primary !== 'undefined' ? !!primary : false 16 | 17 | const base = 'text-xs font-medium mb-2' 18 | const text = isPrimary ? 'text-primary' : '' 19 | let border = '' 20 | if (borderBottom) { 21 | border = isPrimary ? 'border-b border-primary-300 pb-2' : 'border-b border-gray-300 pb-2' 22 | } 23 | 24 | return
{children}
25 | } 26 | 27 | export default Subtitle 28 | -------------------------------------------------------------------------------- /notification_center/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "verbatimModuleSyntax": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true, 23 | 24 | /* Paths */ 25 | "baseUrl": ".", 26 | "paths": { 27 | "@/*": ["./src/*"] 28 | } 29 | }, 30 | "include": ["src", "vite.config.ts"] 31 | } 32 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/toolbars/ToolbarSection.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | import { Divider } from 'antd' 3 | 4 | export interface ToolbarSectionProps { 5 | /** 6 | * Children to render inside the section 7 | */ 8 | children: ReactNode 9 | /** 10 | * Whether to show a divider after this section 11 | * @default true 12 | */ 13 | showDivider?: boolean 14 | } 15 | 16 | /** 17 | * ToolbarSection - Groups related toolbar buttons together 18 | * Optionally shows a divider after the section 19 | */ 20 | export function ToolbarSection({ children, showDivider = true }: ToolbarSectionProps) { 21 | return ( 22 | <> 23 |
{children}
24 | {showDivider && } 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /console/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /console/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test' 2 | 3 | export default defineConfig({ 4 | testDir: './e2e', 5 | fullyParallel: true, 6 | forbidOnly: !!process.env.CI, 7 | retries: process.env.CI ? 2 : 0, 8 | workers: process.env.CI ? 1 : undefined, 9 | reporter: [['html'], ['list']], 10 | timeout: 30000, 11 | use: { 12 | baseURL: 'https://notifusedev.com:5173', 13 | trace: 'on-first-retry', 14 | screenshot: 'only-on-failure', 15 | ignoreHTTPSErrors: true 16 | }, 17 | projects: [ 18 | { 19 | name: 'chromium', 20 | use: { ...devices['Desktop Chrome'] } 21 | } 22 | ], 23 | webServer: { 24 | command: 'npm run dev', 25 | url: 'https://notifusedev.com:5173/console/', 26 | reuseExistingServer: true, 27 | ignoreHTTPSErrors: true, 28 | timeout: 120000 29 | } 30 | }) 31 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/core/registry/action-specs/youtube-action.ts: -------------------------------------------------------------------------------- 1 | import type { Editor } from '@tiptap/react' 2 | import { Youtube } from 'lucide-react' 3 | 4 | // Import YouTube hook functions 5 | import { 6 | canInsertYoutube, 7 | insertYoutube, 8 | isYoutubeActive 9 | } from '../../../hooks/useYoutube' 10 | 11 | import type { ActionDefinition } from '../ActionRegistry' 12 | 13 | /** 14 | * YouTube video embed action definition 15 | */ 16 | export const toYoutubeAction: ActionDefinition = { 17 | id: 'to-youtube', 18 | type: 'transform', 19 | label: 'YouTube', 20 | icon: Youtube, 21 | shortcut: '', 22 | group: 'Media', 23 | checkAvailability: (editor: Editor | null) => canInsertYoutube(editor), 24 | checkActive: (editor: Editor | null) => isYoutubeActive(editor), 25 | execute: (editor: Editor | null) => insertYoutube(editor) 26 | } 27 | -------------------------------------------------------------------------------- /console/src/services/api/email.ts: -------------------------------------------------------------------------------- 1 | import { api } from './client' 2 | import type { EmailProvider } from './workspace' 3 | import type { TestEmailProviderResponse } from './template' 4 | 5 | export const emailService = { 6 | /** 7 | * Test an email provider configuration by sending a test email 8 | * @param workspaceId The ID of the workspace 9 | * @param provider The email provider configuration to test 10 | * @param to The recipient email address for the test 11 | * @returns A response indicating success or failure 12 | */ 13 | testProvider: ( 14 | workspaceId: string, 15 | provider: EmailProvider, 16 | to: string 17 | ): Promise => { 18 | return api.post('/api/email.testProvider', { 19 | provider, 20 | to, 21 | workspace_id: workspaceId 22 | }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /console/src/components/blog/PostStatusTag.tsx: -------------------------------------------------------------------------------- 1 | import { Tag } from 'antd' 2 | import dayjs from '../../lib/dayjs' 3 | import type { BlogPost } from '../../services/api/blog' 4 | 5 | interface PostStatusTagProps { 6 | post: BlogPost 7 | } 8 | 9 | export function PostStatusTag({ post }: PostStatusTagProps) { 10 | if (post.published_at) { 11 | const publishDate = dayjs(post.published_at) 12 | const now = dayjs() 13 | const isFuture = publishDate.isAfter(now) 14 | 15 | if (isFuture) { 16 | return ( 17 | 18 | Scheduled 19 | 20 | ) 21 | } 22 | 23 | return ( 24 | 25 | Published 26 | 27 | ) 28 | } 29 | return ( 30 | 31 | Draft 32 | 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/menus/suggestion/suggestion-menu-core.css: -------------------------------------------------------------------------------- 1 | /* Suggestion Menu Core Styles - Ant Design Menu Overrides */ 2 | 3 | .suggestion-menu-core { 4 | max-width: 400px; 5 | background: white; 6 | border-radius: 8px; 7 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 8 | } 9 | 10 | /* Allow text wrapping in menu items */ 11 | .suggestion-menu-core .ant-menu-item { 12 | white-space: normal; 13 | height: auto; 14 | line-height: normal; 15 | padding: 8px 12px; 16 | } 17 | 18 | /* Group title styling */ 19 | .suggestion-menu-core .ant-menu-item-group-title { 20 | padding: 8px 12px 4px; 21 | font-size: 12px; 22 | font-weight: 600; 23 | color: #8c8c8c; 24 | text-transform: uppercase; 25 | letter-spacing: 0.5px; 26 | } 27 | 28 | /* Remove default Ant Design menu borders */ 29 | .suggestion-menu-core.ant-menu { 30 | border: none; 31 | } 32 | -------------------------------------------------------------------------------- /notification_center/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /internal/service/contact_timeline_service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/Notifuse/notifuse/internal/domain" 7 | ) 8 | 9 | // ContactTimelineService implements domain.ContactTimelineService 10 | type ContactTimelineService struct { 11 | repo domain.ContactTimelineRepository 12 | } 13 | 14 | // NewContactTimelineService creates a new contact timeline service 15 | func NewContactTimelineService(repo domain.ContactTimelineRepository) *ContactTimelineService { 16 | return &ContactTimelineService{ 17 | repo: repo, 18 | } 19 | } 20 | 21 | // List retrieves timeline entries for a contact with pagination 22 | func (s *ContactTimelineService) List(ctx context.Context, workspaceID string, email string, limit int, cursor *string) ([]*domain.ContactTimelineEntry, *string, error) { 23 | return s.repo.List(ctx, workspaceID, email, limit, cursor) 24 | } 25 | -------------------------------------------------------------------------------- /console/src/components/filters/types.ts: -------------------------------------------------------------------------------- 1 | export type FilterType = 'string' | 'number' | 'date' | 'boolean' 2 | 3 | export interface FilterField { 4 | key: string 5 | label: string 6 | type: FilterType 7 | options?: { label: string; value: string | number | boolean }[] 8 | } 9 | 10 | export interface FilterValue { 11 | field: string 12 | value: string | number | boolean | Date 13 | label: string 14 | } 15 | 16 | export interface FilterProps { 17 | fields: FilterField[] 18 | activeFilters: FilterValue[] 19 | className?: string 20 | } 21 | 22 | export interface FilterInputProps { 23 | field: FilterField 24 | value?: string | number | boolean | Date 25 | onChange: (value: string | number | boolean | Date) => void 26 | className?: string 27 | } 28 | 29 | export interface ActiveFiltersProps { 30 | filters: FilterValue[] 31 | onRemove: (field: string) => void 32 | className?: string 33 | } 34 | -------------------------------------------------------------------------------- /console/src/utils/analytics-config.ts: -------------------------------------------------------------------------------- 1 | import { analyticsService } from '../services/api/analytics' 2 | 3 | // Configuration utility for the analytics service 4 | export const configureAnalytics = (maxConcurrency: number = 1) => { 5 | analyticsService.configure({ 6 | maxConcurrency 7 | }) 8 | } 9 | 10 | // Helper function to query analytics with workspace context 11 | export const queryAnalytics = async (query: { schema: string; measures: string[]; dimensions: string[] }, workspaceId: string) => { 12 | return analyticsService.query(query, workspaceId) 13 | } 14 | 15 | // Get analytics service status for debugging 16 | export const getAnalyticsStatus = () => { 17 | return analyticsService.getQueueStatus() 18 | } 19 | 20 | // Default configuration - can be called during app initialization 21 | export const initializeAnalytics = () => { 22 | configureAnalytics(1) // Default to 1 concurrent request 23 | } 24 | -------------------------------------------------------------------------------- /internal/http/utils.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | // WriteJSONError writes a JSON error response with the given message and status code. 9 | // It sets the Content-Type header to application/json and automatically formats 10 | // the response as {"error": "message"}. 11 | func WriteJSONError(w http.ResponseWriter, message string, statusCode int) { 12 | w.Header().Set("Content-Type", "application/json") 13 | w.WriteHeader(statusCode) 14 | _ = json.NewEncoder(w).Encode(map[string]string{ 15 | "error": message, 16 | }) 17 | } 18 | 19 | // writeJSON writes a JSON response with the given status code and data. 20 | // It sets the Content-Type header to application/json. 21 | func writeJSON(w http.ResponseWriter, status int, v interface{}) { 22 | w.Header().Set("Content-Type", "application/json") 23 | w.WriteHeader(status) 24 | _ = json.NewEncoder(w).Encode(v) 25 | } 26 | -------------------------------------------------------------------------------- /console/src/services/api/setup.ts: -------------------------------------------------------------------------------- 1 | import { api } from './client' 2 | import type { 3 | SetupConfig, 4 | SetupStatus, 5 | InitializeResponse, 6 | TestSMTPConfig, 7 | TestSMTPResponse 8 | } from '../../types/setup' 9 | 10 | export const setupApi = { 11 | /** 12 | * Get the current installation status 13 | */ 14 | async getStatus(): Promise { 15 | return api.get('/api/setup.status') 16 | }, 17 | 18 | /** 19 | * Initialize the system with the provided configuration 20 | */ 21 | async initialize(config: SetupConfig): Promise { 22 | return api.post('/api/setup.initialize', config) 23 | }, 24 | 25 | /** 26 | * Test SMTP connection with the provided configuration 27 | */ 28 | async testSmtp(config: TestSMTPConfig): Promise { 29 | return api.post('/api/setup.testSmtp', config) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /notification_center/src/test/setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | import { cleanup } from '@testing-library/react' 3 | import { afterEach, vi } from 'vitest' 4 | 5 | // Cleanup after each test 6 | afterEach(() => { 7 | cleanup() 8 | vi.clearAllMocks() 9 | }) 10 | 11 | // Mock window.matchMedia 12 | Object.defineProperty(window, 'matchMedia', { 13 | writable: true, 14 | value: vi.fn().mockImplementation(query => ({ 15 | matches: false, 16 | media: query, 17 | onchange: null, 18 | addListener: vi.fn(), 19 | removeListener: vi.fn(), 20 | addEventListener: vi.fn(), 21 | removeEventListener: vi.fn(), 22 | dispatchEvent: vi.fn(), 23 | })), 24 | }) 25 | 26 | // Mock IntersectionObserver 27 | global.IntersectionObserver = class IntersectionObserver { 28 | constructor() {} 29 | disconnect() {} 30 | observe() {} 31 | takeRecords() { 32 | return [] 33 | } 34 | unobserve() {} 35 | } as any 36 | 37 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/menus/block-actions/useBlockPositioning.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import { offset } from '@floating-ui/react' 3 | 4 | /** 5 | * Hook that provides positioning configuration for the block actions grip 6 | * Returns positioning middleware for floating-ui 7 | */ 8 | export function useBlockPositioning() { 9 | return useMemo( 10 | () => ({ 11 | middleware: [ 12 | offset((state) => { 13 | const { rects } = state 14 | const blockHeight = rects.reference.height 15 | const gripHeight = rects.floating.height 16 | 17 | const verticalCenter = blockHeight / 2 - gripHeight / 2 18 | 19 | return { 20 | mainAxis: 6, 21 | // For larger blocks, align to top; for smaller ones, center vertically 22 | crossAxis: blockHeight > 40 ? 0 : verticalCenter 23 | } 24 | }) 25 | ] 26 | }), 27 | [] 28 | ) 29 | } 30 | 31 | -------------------------------------------------------------------------------- /internal/service/broadcast/time_provider.go: -------------------------------------------------------------------------------- 1 | package broadcast 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // TimeProvider is an interface that provides time-related functionality 8 | // that can be mocked in tests 9 | type TimeProvider interface { 10 | // Now returns the current time 11 | Now() time.Time 12 | 13 | // Since returns the time elapsed since t 14 | Since(t time.Time) time.Duration 15 | } 16 | 17 | // RealTimeProvider is the default implementation of TimeProvider 18 | // that uses the actual system time 19 | type RealTimeProvider struct{} 20 | 21 | // Now returns the current time 22 | func (rtp RealTimeProvider) Now() time.Time { 23 | return time.Now() 24 | } 25 | 26 | // Since returns the time elapsed since t 27 | func (rtp RealTimeProvider) Since(t time.Time) time.Duration { 28 | return time.Since(t) 29 | } 30 | 31 | // NewRealTimeProvider creates a new RealTimeProvider 32 | func NewRealTimeProvider() TimeProvider { 33 | return &RealTimeProvider{} 34 | } 35 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/hooks/useEditorStyles.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import type { EditorStyleConfig } from '../types/EditorStyleConfig' 3 | import { validateStyleConfig } from '../utils/validateStyleConfig' 4 | import { generateEditorCSSVariables } from '../utils/styleUtils' 5 | 6 | /** 7 | * Hook to validate and convert EditorStyleConfig to CSS variables 8 | * Returns inline styles object ready to apply to the editor wrapper 9 | * 10 | * @param config - Editor style configuration 11 | * @returns CSS properties object with CSS custom properties 12 | * @throws StyleConfigValidationError if config is invalid 13 | */ 14 | export function useEditorStyles(config: EditorStyleConfig): React.CSSProperties { 15 | return useMemo(() => { 16 | // Validate configuration 17 | const validatedConfig = validateStyleConfig(config) 18 | 19 | // Generate CSS variables 20 | return generateEditorCSSVariables(validatedConfig) 21 | }, [config]) 22 | } 23 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/menus/suggestion/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from '@tiptap/pm/model' 2 | 3 | /** 4 | * Calculates the start position of a suggestion command in the text. 5 | * 6 | * @param cursorPosition Current cursor position 7 | * @param previousNode Node before the cursor 8 | * @param triggerChar Character that triggers the suggestion 9 | * @returns The position where the command starts 10 | */ 11 | export function calculateStartPosition( 12 | cursorPosition: number, 13 | previousNode: Node | null, 14 | triggerChar?: string 15 | ): number { 16 | if (!previousNode?.text || !triggerChar) { 17 | return cursorPosition 18 | } 19 | 20 | const commandText = previousNode.text 21 | const triggerCharIndex = commandText.lastIndexOf(triggerChar) 22 | 23 | if (triggerCharIndex === -1) { 24 | return cursorPosition 25 | } 26 | 27 | const textLength = commandText.substring(triggerCharIndex).length 28 | 29 | return cursorPosition - textLength 30 | } 31 | -------------------------------------------------------------------------------- /internal/domain/webhook_provider.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import "context" 4 | 5 | //go:generate mockgen -destination mocks/mock_webhook_provider.go -package mocks github.com/Notifuse/notifuse/internal/domain WebhookProvider 6 | 7 | // WebhookProvider defines a common interface for all email providers that support webhooks 8 | type WebhookProvider interface { 9 | // RegisterWebhooks registers webhooks for the specified events 10 | RegisterWebhooks(ctx context.Context, workspaceID, integrationID string, baseURL string, eventTypes []EmailEventType, providerConfig *EmailProvider) (*WebhookRegistrationStatus, error) 11 | 12 | // GetWebhookStatus checks the current status of webhooks 13 | GetWebhookStatus(ctx context.Context, workspaceID, integrationID string, providerConfig *EmailProvider) (*WebhookRegistrationStatus, error) 14 | 15 | // UnregisterWebhooks removes all webhooks for this integration 16 | UnregisterWebhooks(ctx context.Context, workspaceID, integrationID string, providerConfig *EmailProvider) error 17 | } 18 | -------------------------------------------------------------------------------- /tests/compose.test.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres-test: 3 | image: postgres:17 4 | command: 5 | - 'postgres' 6 | - '-c' 7 | - 'max_connections=300' 8 | - '-c' 9 | - 'shared_buffers=128MB' 10 | environment: 11 | POSTGRES_DB: postgres 12 | POSTGRES_USER: notifuse_test 13 | POSTGRES_PASSWORD: test_password 14 | ports: 15 | - '5433:5432' 16 | volumes: 17 | - postgres_test_data:/var/lib/postgresql/data 18 | healthcheck: 19 | test: ['CMD-SHELL', 'pg_isready -U notifuse_test -d postgres'] 20 | interval: 10s 21 | timeout: 5s 22 | retries: 5 23 | 24 | mailpit: 25 | image: axllent/mailpit:latest 26 | ports: 27 | - '1025:1025' # SMTP server 28 | - '8025:8025' # Web UI 29 | healthcheck: 30 | test: ['CMD', 'wget', '--quiet', '--tries=1', '--spider', 'http://localhost:8025/'] 31 | interval: 10s 32 | timeout: 5s 33 | retries: 3 34 | 35 | volumes: 36 | postgres_test_data: 37 | driver: local 38 | -------------------------------------------------------------------------------- /console/src/components/blog/DeletePostModal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal, Typography } from 'antd' 2 | import type { BlogPost } from '../../services/api/blog' 3 | 4 | const { Paragraph } = Typography 5 | 6 | interface DeletePostModalProps { 7 | open: boolean 8 | post: BlogPost | null 9 | onConfirm: () => void 10 | onCancel: () => void 11 | loading: boolean 12 | } 13 | 14 | export function DeletePostModal({ 15 | open, 16 | post, 17 | onConfirm, 18 | onCancel, 19 | loading 20 | }: DeletePostModalProps) { 21 | return ( 22 | 31 | 32 | Are you sure you want to delete the post {post?.settings.title}? 33 | 34 | This action cannot be undone. 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /internal/service/broadcast/config_test.go: -------------------------------------------------------------------------------- 1 | package broadcast_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/Notifuse/notifuse/internal/service/broadcast" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | // TestDefaultConfig ensures the default config has expected values 12 | func TestDefaultConfig(t *testing.T) { 13 | config := broadcast.DefaultConfig() 14 | 15 | // Verify default config values 16 | assert.NotNil(t, config) 17 | assert.Equal(t, 10, config.MaxParallelism) 18 | assert.Equal(t, 50*time.Second, config.MaxProcessTime) 19 | assert.Equal(t, 50, config.FetchBatchSize) 20 | assert.Equal(t, 25, config.ProcessBatchSize) 21 | assert.Equal(t, 5*time.Second, config.ProgressLogInterval) 22 | assert.Equal(t, true, config.EnableCircuitBreaker) 23 | assert.Equal(t, 5, config.CircuitBreakerThreshold) 24 | assert.Equal(t, 1*time.Minute, config.CircuitBreakerCooldown) 25 | assert.Equal(t, 25, config.DefaultRateLimit) 26 | assert.Equal(t, 3, config.MaxRetries) 27 | assert.Equal(t, 30*time.Second, config.RetryInterval) 28 | } 29 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/menus/block-actions/PrimaryActionsGroup.tsx: -------------------------------------------------------------------------------- 1 | import type { MenuProps } from 'antd' 2 | import { useActionsArray } from '../../core/registry/action-specs' 3 | import { createActionMenuItem } from './ActionButton' 4 | 5 | /** 6 | * PrimaryActionsGroup - Main actions like duplicate, copy, and copy anchor link 7 | * Returns menu items configuration for Antd Menu 8 | * Now using the action registry for improved performance 9 | */ 10 | export function usePrimaryActionsGroup(): MenuProps['items'] { 11 | // Get all primary actions from registry with single event listener 12 | const actions = useActionsArray(['duplicate', 'copy-to-clipboard', 'copy-anchor-link'], { 13 | hideWhenUnavailable: false 14 | }) 15 | 16 | return [ 17 | ...actions.map((action) => 18 | createActionMenuItem({ 19 | icon: action.icon!, 20 | label: action.label!, 21 | action: action.execute, 22 | disabled: !action.isAvailable 23 | }) 24 | ), 25 | { type: 'divider', key: 'primary-divider' } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/toolbars/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Default toolbar configuration 3 | * Defines the layout and actions for the floating selection toolbar 4 | */ 5 | 6 | /** 7 | * Action IDs for the left section (block transformations) 8 | */ 9 | export const LEFT_SECTION_ACTIONS = ['turn-into'] // Special component 10 | 11 | /** 12 | * Action IDs for the center section (text formatting marks) 13 | */ 14 | export const CENTER_SECTION_ACTIONS = ['bold', 'italic', 'underline', 'strike', 'code'] 15 | 16 | /** 17 | * Action IDs for the right section (link, color, and more options) 18 | */ 19 | export const RIGHT_SECTION_ACTIONS = ['link', 'color', 'more'] // Special components 20 | 21 | /** 22 | * Default toolbar layout configuration 23 | * 24 | * Layout: 25 | * [TurnIntoDropdown] | [B] [I] [U] [S] [] | [Link] [Color] [More] 26 | */ 27 | export const DEFAULT_TOOLBAR_CONFIG = { 28 | leftActions: LEFT_SECTION_ACTIONS, 29 | centerActions: CENTER_SECTION_ACTIONS, 30 | rightActions: RIGHT_SECTION_ACTIONS, 31 | hideWhenUnavailable: true 32 | } 33 | -------------------------------------------------------------------------------- /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./tmp/main" 8 | cmd = "go build -o ./tmp/main ./cmd/api" 9 | delay = 1000 10 | exclude_dir = ["docs", "console", "notification_center", ".git", ".github", "assets", "tmp", "vendor", "testdata", "node_modules"] 11 | exclude_file = [] 12 | exclude_regex = ["_test.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "html"] 18 | include_file = [] 19 | kill_delay = "0s" 20 | log = "build-errors.log" 21 | poll = false 22 | poll_interval = 0 23 | rerun = true 24 | rerun_delay = 500 25 | send_interrupt = false 26 | stop_on_error = true 27 | 28 | [color] 29 | app = "" 30 | build = "yellow" 31 | main = "magenta" 32 | runner = "green" 33 | watcher = "cyan" 34 | 35 | [log] 36 | main_only = false 37 | time = false 38 | 39 | [misc] 40 | clean_on_exit = false 41 | 42 | [screen] 43 | clear_on_rebuild = false 44 | keep_scroll = true -------------------------------------------------------------------------------- /internal/http/middleware/cors.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | ) 7 | 8 | // CORSMiddleware handles CORS headers for all requests 9 | func CORSMiddleware(next http.Handler) http.Handler { 10 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 11 | // Get allowed origin from environment variable with default value "*" 12 | allowOrigin := os.Getenv("CORS_ALLOW_ORIGIN") 13 | if allowOrigin == "" { 14 | allowOrigin = "*" 15 | } 16 | w.Header().Set("Access-Control-Allow-Origin", allowOrigin) 17 | 18 | // Allow specific HTTP methods 19 | w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") 20 | 21 | // Allow specific headers 22 | w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, Authorization") 23 | 24 | // Allow credentials 25 | w.Header().Set("Access-Control-Allow-Credentials", "true") 26 | 27 | // Handle preflight requests 28 | if r.Method == "OPTIONS" { 29 | w.WriteHeader(http.StatusOK) 30 | return 31 | } 32 | 33 | next.ServeHTTP(w, r) 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Notifuse Editor - Public API 3 | * 4 | * Main entry point for the blog editor with dynamic styling support 5 | */ 6 | 7 | // Main component 8 | export { NotifuseEditor, DEFAULT_INITIAL_CONTENT } from './NotifuseEditor' 9 | export type { NotifuseEditorProps, NotifuseEditorRef, TOCAnchor } from './NotifuseEditor' 10 | 11 | // Types 12 | export type { 13 | EditorStyleConfig, 14 | CSSValue, 15 | DefaultStyles, 16 | ParagraphStyles, 17 | HeadingStyles, 18 | HeadingLevelStyles, 19 | CaptionStyles, 20 | SeparatorStyles, 21 | CodeBlockStyles, 22 | BlockquoteStyles, 23 | InlineCodeStyles, 24 | ListStyles, 25 | LinkStyles 26 | } from './types/EditorStyleConfig' 27 | 28 | // Default configuration 29 | export { defaultEditorStyles } from './config/defaultEditorStyles' 30 | 31 | // Style presets 32 | export { 33 | academicPaperPreset 34 | } from './presets' 35 | 36 | // Utility functions 37 | export { generateBlogPostCSS, clearCSSCache } from './utils/styleUtils' 38 | export { validateStyleConfig, StyleConfigValidationError } from './utils/validateStyleConfig' 39 | -------------------------------------------------------------------------------- /console/src/components/email_builder/ui/tiptap/TiptapComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { TiptapRichEditor } from './TiptapRichEditor' 3 | import { TiptapInlineEditor } from './TiptapInlineEditor' 4 | import type { BaseTiptapProps } from './shared/types' 5 | 6 | // Extended props interface that includes the inline prop for backward compatibility 7 | export interface TiptapComponentProps extends BaseTiptapProps { 8 | inline?: boolean // Determines which editor variant to use 9 | } 10 | 11 | /** 12 | * Backward-compatible TiptapComponent that automatically chooses between 13 | * TiptapRichEditor and TiptapInlineEditor based on the inline prop. 14 | * 15 | * @deprecated Consider using TiptapRichEditor or TiptapInlineEditor directly for better type safety and performance. 16 | */ 17 | export const TiptapComponent: React.FC = ({ inline = false, ...props }) => { 18 | // Choose the appropriate editor based on the inline prop 19 | if (inline) { 20 | return 21 | } 22 | 23 | return 24 | } 25 | 26 | export default TiptapComponent 27 | -------------------------------------------------------------------------------- /console/src/components/blog/DeleteCategoryModal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal, Typography } from 'antd' 2 | import type { BlogCategory } from '../../services/api/blog' 3 | 4 | const { Paragraph } = Typography 5 | 6 | interface DeleteCategoryModalProps { 7 | open: boolean 8 | category: BlogCategory | null 9 | onConfirm: () => void 10 | onCancel: () => void 11 | loading: boolean 12 | } 13 | 14 | export function DeleteCategoryModal({ 15 | open, 16 | category, 17 | onConfirm, 18 | onCancel, 19 | loading 20 | }: DeleteCategoryModalProps) { 21 | return ( 22 | 31 | 32 | Are you sure you want to delete the category {category?.settings.name}? 33 | 34 | 35 | Both the category and all its associated posts will be unpublished from the web. 36 | 37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /console/src/types/setup.ts: -------------------------------------------------------------------------------- 1 | export interface SetupConfig { 2 | root_email?: string 3 | api_endpoint?: string 4 | smtp_host?: string 5 | smtp_port?: number 6 | smtp_username?: string 7 | smtp_password?: string 8 | smtp_from_email?: string 9 | smtp_from_name?: string 10 | smtp_use_tls?: boolean 11 | telemetry_enabled?: boolean 12 | check_for_updates?: boolean 13 | smtp_relay_enabled?: boolean 14 | smtp_relay_domain?: string 15 | smtp_relay_port?: number 16 | smtp_relay_tls_cert_base64?: string 17 | smtp_relay_tls_key_base64?: string 18 | } 19 | 20 | export interface SetupStatus { 21 | is_installed: boolean 22 | smtp_configured: boolean 23 | api_endpoint_configured: boolean 24 | root_email_configured: boolean 25 | smtp_relay_configured: boolean 26 | } 27 | 28 | export interface InitializeResponse { 29 | success: boolean 30 | message: string 31 | } 32 | 33 | export interface TestSMTPConfig { 34 | smtp_host: string 35 | smtp_port: number 36 | smtp_username: string 37 | smtp_password: string 38 | smtp_use_tls?: boolean 39 | } 40 | 41 | export interface TestSMTPResponse { 42 | success: boolean 43 | message: string 44 | } 45 | -------------------------------------------------------------------------------- /tests/testdata/certs/README.md: -------------------------------------------------------------------------------- 1 | # Test TLS Certificates 2 | 3 | This directory contains self-signed TLS certificates for testing purposes only. 4 | 5 | ## Files 6 | 7 | - `test_cert.pem`: Self-signed TLS certificate 8 | - `test_key.pem`: Private key for the certificate 9 | 10 | ## Usage 11 | 12 | These certificates are used in the SMTP relay end-to-end tests to enable TLS authentication without requiring real certificates. 13 | 14 | **WARNING**: These are self-signed certificates and should NEVER be used in production. They are for testing purposes only. 15 | 16 | ## Regenerating Certificates 17 | 18 | If you need to regenerate the certificates (e.g., if they expire), run: 19 | 20 | ```bash 21 | cd testdata/certs 22 | openssl req -x509 -newkey rsa:2048 -keyout test_key.pem -out test_cert.pem -days 3650 -nodes \ 23 | -subj "/C=US/ST=Test/L=Test/O=Notifuse Test/CN=localhost" 24 | ``` 25 | 26 | The certificates are valid for 10 years from the generation date. 27 | 28 | ## Security Note 29 | 30 | These certificates use SHA-256 with RSA encryption and are sufficient for local testing. The `-nodes` flag means the private key is not encrypted with a passphrase, which is appropriate for automated testing but should never be done with production certificates. 31 | -------------------------------------------------------------------------------- /console/src/components/contacts/ImportContactsButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Button } from 'antd' 3 | import { UploadOutlined } from '@ant-design/icons' 4 | import { useContactsCsvUpload } from './ContactsCsvUploadProvider' 5 | import { List } from '../../services/api/types' 6 | 7 | interface ImportContactsButtonProps { 8 | className?: string 9 | style?: React.CSSProperties 10 | type?: 'primary' | 'default' | 'dashed' | 'link' | 'text' 11 | size?: 'large' | 'middle' | 'small' 12 | lists?: List[] 13 | workspaceId: string 14 | refreshOnClose?: boolean 15 | disabled?: boolean 16 | } 17 | 18 | export function ImportContactsButton({ 19 | className, 20 | style, 21 | type = 'primary', 22 | size = 'middle', 23 | lists = [], 24 | workspaceId, 25 | refreshOnClose = true, 26 | disabled = false 27 | }: ImportContactsButtonProps) { 28 | const { openDrawer } = useContactsCsvUpload() 29 | 30 | return ( 31 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /console/src/components/segment/table_tag.tsx: -------------------------------------------------------------------------------- 1 | import { Tag } from 'antd' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import { faMousePointer } from '@fortawesome/free-solid-svg-icons' 4 | import { faUser, faFolderOpen } from '@fortawesome/free-regular-svg-icons' 5 | 6 | export interface TableTagProps { 7 | table: string 8 | } 9 | const TableTag = (props: TableTagProps) => { 10 | // magenta red volcano orange gold lime green cyan blue geekblue purple 11 | const table = props.table.toLowerCase() 12 | let color = 'geekblue' 13 | let label = props.table 14 | let icon = null 15 | 16 | if (table === 'contacts') { 17 | color = 'lime' 18 | label = 'Contact property' 19 | icon = faUser 20 | } 21 | if (table === 'contact_lists') { 22 | color = 'magenta' 23 | label = 'List subscription' 24 | icon = faFolderOpen 25 | } 26 | if (table === 'contact_timeline') { 27 | color = 'cyan' 28 | label = 'Activity' 29 | icon = faMousePointer 30 | } 31 | 32 | return ( 33 | 34 | {icon && } 35 | {label} 36 | 37 | ) 38 | } 39 | 40 | export default TableTag 41 | -------------------------------------------------------------------------------- /telemetry/.gcloudignore: -------------------------------------------------------------------------------- 1 | # This file specifies files that are *not* uploaded to Google Cloud 2 | # using gcloud. It follows the same syntax as .gitignore, with the addition of 3 | # "#!include" directives (which insert the entries of the given .gitignore-style 4 | # file at that point). 5 | # 6 | # For more information, run: 7 | # $ gcloud topic gcloudignore 8 | # 9 | .gcloudignore 10 | # If you would like to upload your .git directory, .gitignore file or files 11 | # from your .gitignore file, remove the corresponding line below: 12 | .git 13 | .gitignore 14 | 15 | # Node.js dependencies: 16 | node_modules/ 17 | 18 | # Python pycache: 19 | __pycache__/ 20 | # Ignored by the build system 21 | /setup.cfg 22 | 23 | # Go build artifacts 24 | *.exe 25 | *.exe~ 26 | *.dll 27 | *.so 28 | *.dylib 29 | 30 | # Test binary, built with `go test -c` 31 | *.test 32 | 33 | # Output of the go coverage tool 34 | *.out 35 | 36 | # Dependency directories 37 | vendor/ 38 | 39 | # IDE files 40 | .vscode/ 41 | .idea/ 42 | *.swp 43 | *.swo 44 | 45 | # OS generated files 46 | .DS_Store 47 | .DS_Store? 48 | ._* 49 | .Spotlight-V100 50 | .Trashes 51 | ehthumbs.db 52 | Thumbs.db 53 | 54 | # Local development files 55 | .env 56 | .env.local 57 | *.log 58 | 59 | # Documentation 60 | README.md 61 | -------------------------------------------------------------------------------- /console/src/components/templates/TemplateSelector.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Form } from 'antd' 3 | import TemplateSelectorInput from './TemplateSelectorInput' 4 | import { Rule } from 'antd/es/form' 5 | 6 | interface TemplateSelectorProps { 7 | name: string 8 | label: string 9 | workspaceId: string 10 | category?: 11 | | 'marketing' 12 | | 'transactional' 13 | | 'welcome' 14 | | 'opt_in' 15 | | 'unsubscribe' 16 | | 'bounce' 17 | | 'blocklist' 18 | | 'other' 19 | placeholder?: string 20 | required?: boolean 21 | rules?: Rule[] 22 | } 23 | 24 | const TemplateSelector: React.FC = ({ 25 | name, 26 | label, 27 | workspaceId, 28 | category, 29 | placeholder, 30 | required = false, 31 | rules = [] 32 | }) => { 33 | const defaultRules = required ? [{ required: true, message: `Please select a template` }] : [] 34 | const combinedRules = [...defaultRules, ...rules] 35 | 36 | return ( 37 | 38 | 43 | 44 | ) 45 | } 46 | 47 | export default TemplateSelector 48 | -------------------------------------------------------------------------------- /console/src/__tests__/setup.tsx: -------------------------------------------------------------------------------- 1 | import { afterEach, vi } from 'vitest' 2 | import { cleanup } from '@testing-library/react' 3 | import '@testing-library/jest-dom/vitest' 4 | 5 | // Mock window.matchMedia for Ant Design 6 | Object.defineProperty(window, 'matchMedia', { 7 | writable: true, 8 | value: vi.fn().mockImplementation((query) => ({ 9 | matches: false, 10 | media: query, 11 | onchange: null, 12 | addListener: vi.fn(), 13 | removeListener: vi.fn(), 14 | addEventListener: vi.fn(), 15 | removeEventListener: vi.fn(), 16 | dispatchEvent: vi.fn() 17 | })) 18 | }) 19 | 20 | // Mock Ant Design message component 21 | vi.mock('antd', async () => { 22 | const actual = await vi.importActual('antd') 23 | return { 24 | ...actual, 25 | message: { 26 | success: vi.fn(), 27 | error: vi.fn(), 28 | info: vi.fn(), 29 | warning: vi.fn(), 30 | loading: vi.fn() 31 | } 32 | } 33 | }) 34 | 35 | // Setup mocks 36 | vi.mock('@tanstack/react-router', async () => { 37 | const actual = await vi.importActual('@tanstack/react-router') 38 | return { 39 | ...actual, 40 | useNavigate: () => vi.fn(), 41 | useMatch: () => false 42 | } 43 | }) 44 | 45 | // Clean up after each test 46 | afterEach(() => { 47 | cleanup() 48 | }) 49 | -------------------------------------------------------------------------------- /console/src/components/segment/operator_number.tsx: -------------------------------------------------------------------------------- 1 | import { Form, InputNumber, Tag } from 'antd' 2 | import Messages from './messages' 3 | import { DimensionFilter, IOperator, Operator } from '../../services/api/segment' 4 | 5 | export type OperatorNumberProps = { 6 | value: string | undefined 7 | } 8 | 9 | export class OperatorNumber implements IOperator { 10 | type: Operator = 'gt' 11 | label = 'greater than' 12 | 13 | constructor(overrideType?: Operator, overrideLabel?: string) { 14 | if (overrideType) this.type = overrideType 15 | if (overrideLabel) this.label = overrideLabel 16 | } 17 | 18 | render(filter: DimensionFilter) { 19 | return ( 20 | <> 21 | {this.label} 22 | 23 | 24 | {filter.number_values?.[0]} 25 | 26 | 27 | 28 | ) 29 | } 30 | 31 | renderFormItems() { 32 | return ( 33 | 38 | 39 | 40 | ) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /console/public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /console/src/services/api/auth.ts: -------------------------------------------------------------------------------- 1 | import { api } from './client' 2 | import type { Workspace } from './workspace' 3 | 4 | // Authentication types 5 | export interface SignInRequest { 6 | email: string 7 | } 8 | 9 | export interface SignInResponse { 10 | message: string 11 | code?: string 12 | } 13 | 14 | export interface VerifyCodeRequest { 15 | email: string 16 | code: string 17 | } 18 | 19 | export interface VerifyResponse { 20 | token: string 21 | } 22 | 23 | export interface GetCurrentUserResponse { 24 | user: { 25 | id: string 26 | email: string 27 | timezone: string 28 | } 29 | workspaces: Workspace[] 30 | } 31 | 32 | /** 33 | * Check if the current user is the root user 34 | */ 35 | export function isRootUser(userEmail?: string): boolean { 36 | if (!userEmail || !window.ROOT_EMAIL) { 37 | return false 38 | } 39 | return userEmail === window.ROOT_EMAIL 40 | } 41 | 42 | export interface LogoutResponse { 43 | message: string 44 | } 45 | 46 | export const authService = { 47 | signIn: (data: SignInRequest) => api.post('/api/user.signin', data), 48 | verifyCode: (data: VerifyCodeRequest) => api.post('/api/user.verify', data), 49 | getCurrentUser: () => api.get('/api/user.me'), 50 | logout: () => api.post('/api/user.logout', {}) 51 | } 52 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/hooks/useCodeBlock.ts: -------------------------------------------------------------------------------- 1 | import type { Editor } from '@tiptap/react' 2 | 3 | /** 4 | * Checks if code block transformation is available 5 | * 6 | * Validates: 7 | * - Editor is ready and editable 8 | * - Code block node type exists in schema 9 | * - Current selection can be converted to code block 10 | * 11 | * @param editor - The Tiptap editor instance 12 | * @returns true if code block toggle is available, false otherwise 13 | */ 14 | export function canToggle(editor: Editor | null): boolean { 15 | if (!editor || !editor.isEditable) return false 16 | 17 | // Check if codeBlock node exists in schema 18 | if (!editor.schema.nodes.codeBlock) return false 19 | 20 | // Check if we can toggle code block 21 | return editor.can().toggleCodeBlock() 22 | } 23 | 24 | /** 25 | * Toggles the current block to/from a code block 26 | * 27 | * If already a code block, converts to paragraph 28 | * Otherwise, converts to a code block 29 | * 30 | * @param editor - The Tiptap editor instance 31 | * @returns true if toggle succeeded, false otherwise 32 | */ 33 | export function toggleCodeBlock(editor: Editor | null): boolean { 34 | if (!editor || !editor.isEditable) return false 35 | 36 | try { 37 | return editor.chain().focus().toggleCodeBlock().run() 38 | } catch { 39 | return false 40 | } 41 | } 42 | 43 | -------------------------------------------------------------------------------- /console/certificates/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDjzCCAnegAwIBAgIUBEvSZfABSgx8hE5/9rdSQwXMg+gwDQYJKoZIhvcNAQEL 3 | BQAwVzELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVN0YXRlMQ0wCwYDVQQHDARDaXR5 4 | MRUwEwYDVQQKDAxPcmdhbml6YXRpb24xEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0y 5 | NTA0MDIxMTM5MDNaFw0zNTAzMzExMTM5MDNaMFcxCzAJBgNVBAYTAlVTMQ4wDAYD 6 | VQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEVMBMGA1UECgwMT3JnYW5pemF0aW9u 7 | MRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK 8 | AoIBAQCT3ODyw31ggD9+W5DE+4ZXy9ekJGcGLzUbI8EjUGZCHyrfTwB+xJQNVoyf 9 | y7klxg8repqZEqFtqFWzVLwUAkrl0hlcbrWI200i7LhbNiT3TWL44G98mn1yHZFU 10 | SkkqvU6vofOwMKvlVYM/bey2IdZYUJq+nRRupKWETZPPgtJtzhzRWqrXbkRLdEfm 11 | lRrqOb8Uv3CsiWAM5sKveOQgOFb/CDsuUIwVHRSTws4gScSyZEivWtitPsesJ9I1 12 | 8YJwO4elM5I9snLFnngAwCEgJOX1zhjbavT+O+tXobBLkkjBDZr7ASAQkSbsO2FM 13 | 1p+r1HFbqV6NIU4MQ19DYwEGtBz1AgMBAAGjUzBRMB0GA1UdDgQWBBSowHDgUe0L 14 | Pq8XbyEh7iKXH05XmzAfBgNVHSMEGDAWgBSowHDgUe0LPq8XbyEh7iKXH05XmzAP 15 | BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAO8lRfLBIHe/EJ0ur6 16 | F1AyRVCvPdJcCVRO7bboEnbwEsKzA+6hzKm6v86WfERAYAINmYm3yTttoaWI8Izn 17 | SaeILW+MZ/VGD3u+1H/3CQHM1iMdrxjE6hvexpRr7YQebHSYjiI6DDtr9U6S+2e3 18 | p6EWdPGCc1PTuAFxdv+2xkNrJHaWyq/gGnSWf25nqekI/o/O/n6oMsC51N6aAoh/ 19 | Q4QaavJc1FTyzSqs92aqs53LTksAvhapOap9ZoPDUkaPZ9YzZdhNHvoIq9rpf2py 20 | 6reit89oM7eVCaI5smhXimIGfzwbLjjaN2KUlT8icxhXBd4qFN2Lj9+L3sq9pXJr 21 | ZnsF 22 | -----END CERTIFICATE----- 23 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/hooks/useBlockquote.ts: -------------------------------------------------------------------------------- 1 | import type { Editor } from '@tiptap/react' 2 | 3 | /** 4 | * Checks if blockquote transformation is available 5 | * 6 | * Validates: 7 | * - Editor is ready and editable 8 | * - Blockquote node type exists in schema 9 | * - Current selection can be converted to blockquote 10 | * 11 | * @param editor - The Tiptap editor instance 12 | * @returns true if blockquote toggle is available, false otherwise 13 | */ 14 | export function canToggleBlockquote(editor: Editor | null): boolean { 15 | if (!editor || !editor.isEditable) return false 16 | 17 | // Check if blockquote node exists in schema 18 | if (!editor.schema.nodes.blockquote) return false 19 | 20 | // Check if we can toggle blockquote 21 | return editor.can().toggleBlockquote() 22 | } 23 | 24 | /** 25 | * Toggles the current block to/from a blockquote 26 | * 27 | * If already a blockquote, converts to normal blocks 28 | * Otherwise, wraps the selection in a blockquote 29 | * 30 | * @param editor - The Tiptap editor instance 31 | * @returns true if toggle succeeded, false otherwise 32 | */ 33 | export function toggleBlockquote(editor: Editor | null): boolean { 34 | if (!editor || !editor.isEditable) return false 35 | 36 | try { 37 | return editor.chain().focus().toggleBlockquote().run() 38 | } catch { 39 | return false 40 | } 41 | } 42 | 43 | -------------------------------------------------------------------------------- /console/src/components/file_manager/interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface FileInfo { 2 | size: number 3 | size_human: string 4 | content_type: string // guessed from the file extension 5 | url: string 6 | } 7 | 8 | export interface StorageObject { 9 | key: string 10 | name: string 11 | is_folder: boolean 12 | path: string 13 | last_modified: Date 14 | file_info: FileInfo 15 | } 16 | 17 | export interface FileManagerProps { 18 | currentPath?: string 19 | itemFilters?: ItemFilter[] 20 | onError: (error: Error) => void 21 | onSelect: (items: StorageObject[]) => void 22 | height: number 23 | acceptFileType: string 24 | acceptItem: (item: StorageObject) => boolean 25 | withSelection?: boolean 26 | multiple?: boolean 27 | settings?: FileManagerSettings 28 | onUpdateSettings: (settings: FileManagerSettings) => Promise 29 | settingsInfo?: React.ReactNode 30 | readOnly?: boolean 31 | } 32 | 33 | export interface ItemFilter { 34 | key: string // item key 35 | value: string | number | boolean 36 | operator: string // contains equals greaterThan lessThan 37 | } 38 | 39 | export interface FileManagerSettings { 40 | provider?: string 41 | endpoint: string 42 | access_key: string 43 | bucket: string 44 | region?: string 45 | secret_key?: string 46 | encrypted_secret_key?: string 47 | cdn_endpoint?: string 48 | force_path_style?: boolean 49 | } 50 | -------------------------------------------------------------------------------- /console/src/components/lists/ImportContactsToListButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Button, Tooltip } from 'antd' 3 | import { UploadOutlined } from '@ant-design/icons' 4 | import { useContactsCsvUpload } from '../contacts/ContactsCsvUploadProvider' 5 | import { List } from '../../services/api/types' 6 | 7 | interface ImportContactsToListButtonProps { 8 | list: List 9 | workspaceId: string 10 | lists?: List[] 11 | size?: 'large' | 'middle' | 'small' 12 | type?: 'default' | 'primary' | 'dashed' | 'link' | 'text' 13 | className?: string 14 | style?: React.CSSProperties 15 | disabled?: boolean 16 | } 17 | 18 | export function ImportContactsToListButton({ 19 | list, 20 | workspaceId, 21 | lists = [], 22 | size = 'small', 23 | type = 'text', 24 | className, 25 | style, 26 | disabled = false 27 | }: ImportContactsToListButtonProps) { 28 | const { openDrawerWithSelectedList } = useContactsCsvUpload() 29 | 30 | const handleClick = () => { 31 | // Pass true for refreshOnClose to refresh contacts data 32 | openDrawerWithSelectedList(workspaceId, lists, list.id, true) 33 | } 34 | 35 | return ( 36 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /tests/testdata/certs/test_cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDjzCCAnegAwIBAgIUayrvYxmLUfUexfcZ7dau/orFUr4wDQYJKoZIhvcNAQEL 3 | BQAwVzELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3Qx 4 | FjAUBgNVBAoMDU5vdGlmdXNlIFRlc3QxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0y 5 | NTExMDUxODQyMDZaFw0zNTExMDMxODQyMDZaMFcxCzAJBgNVBAYTAlVTMQ0wCwYD 6 | VQQIDARUZXN0MQ0wCwYDVQQHDARUZXN0MRYwFAYDVQQKDA1Ob3RpZnVzZSBUZXN0 7 | MRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK 8 | AoIBAQCeTZ0Sqa5HNp7w4iotHqdqM/1rkR/cRxqNTAsDeQb8MPLwk34CXV6igE52 9 | pHXWeU9lZokGHCgxc67VgAN2Vd/zx7dbzqW2vut0ohZxYHaC6gakwFdBjLRYF6Es 10 | c7EU42kwLttDe8nNefVRlxXIyBpDzdr2MkGIrtzhsKO4CS1mJM+0wdhNJl2txTcJ 11 | IkQmrbhZaTCLuEvVGEvQRTBtojpyowr4ljGD8bFTwU6RUGCadmeyMj3Nh3v+raMB 12 | W45ydCG4mr+Mw9lP5VZI961Sc9NT4YggpsnjmD7z1ltVjAb4m+IOK9IHQFAb9Wty 13 | eRi5QrVz8dv5kdn9ivaT03y2iAGJAgMBAAGjUzBRMB0GA1UdDgQWBBSVmNBoBz3v 14 | J2260yNYrHVuCbFM/jAfBgNVHSMEGDAWgBSVmNBoBz3vJ2260yNYrHVuCbFM/jAP 15 | BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAV7HReYM6d0u+qjZMg 16 | OkNDlDxrtEbL1cKTU18Rnl5pRapmTKRzSPkXXWgMWjqckZYXnjKnfY9IrkRi+V4P 17 | +WCDI/W/Zk5O6cb3/CjDzz+jzIkSYGeSeGf98P5YO8wtGP+Cas3pjmhREziUrZTD 18 | Ve/nUlxciQOGhl7UQOAQpfEQ3LfUWOsu1VUjptw+Dw+JAaEJeLIKOJqJIIxuDjfs 19 | GG42VK8r5RQVe10CAZ3Z/9yJ5PuikcB8V6bx7YAcMP/LD8OSzIItVSPuSCqPX/0h 20 | 73TsVY2by+EStFnbFlxHcxuJU/jvp8/tfxBsST0U0y6gDeXFNfWoaNHo4rCukUR0 21 | +vx3 22 | -----END CERTIFICATE----- 23 | -------------------------------------------------------------------------------- /internal/domain/contact_segment_queue.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | //go:generate mockgen -destination mocks/mock_contact_segment_queue_repository.go -package mocks github.com/Notifuse/notifuse/internal/domain ContactSegmentQueueRepository 9 | 10 | // ContactSegmentQueueItem represents a contact that needs segment recomputation 11 | type ContactSegmentQueueItem struct { 12 | Email string `json:"email"` 13 | QueuedAt time.Time `json:"queued_at"` 14 | } 15 | 16 | // ContactSegmentQueueRepository defines the interface for contact segment queue operations 17 | type ContactSegmentQueueRepository interface { 18 | // GetPendingEmails retrieves emails that need segment recomputation 19 | GetPendingEmails(ctx context.Context, workspaceID string, limit int) ([]string, error) 20 | 21 | // RemoveFromQueue removes an email from the queue after processing 22 | RemoveFromQueue(ctx context.Context, workspaceID string, email string) error 23 | 24 | // RemoveBatchFromQueue removes multiple emails from the queue 25 | RemoveBatchFromQueue(ctx context.Context, workspaceID string, emails []string) error 26 | 27 | // GetQueueSize returns the number of contacts in the queue 28 | GetQueueSize(ctx context.Context, workspaceID string) (int, error) 29 | 30 | // ClearQueue removes all items from the queue 31 | ClearQueue(ctx context.Context, workspaceID string) error 32 | } 33 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/menus/block-actions/useBlockTransformations.ts: -------------------------------------------------------------------------------- 1 | import { useActionsArray } from '../../core/registry/action-specs' 2 | import type { ActionItemConfig } from './block-actions-types' 3 | 4 | /** 5 | * Hook that provides block transformation actions using the action registry 6 | * Returns array of transformation options or null if none available 7 | */ 8 | export function useBlockTransformations(): Omit[] | null { 9 | // Define the transformation action IDs we want to use 10 | const transformIds = [ 11 | 'to-paragraph', 12 | 'to-heading-1', 13 | 'to-heading-2', 14 | 'to-heading-3', 15 | 'to-bullet-list', 16 | 'to-numbered-list', 17 | 'to-quote', 18 | 'to-code-block' 19 | ] 20 | 21 | // Get all actions from the registry efficiently (single set of event listeners) 22 | const actions = useActionsArray(transformIds, { hideWhenUnavailable: true }) 23 | 24 | // Convert registry actions to the ActionItemConfig format 25 | const transformations = actions.map((action) => ({ 26 | icon: action.icon!, 27 | label: action.label!, 28 | action: action.execute, 29 | disabled: !action.isAvailable, 30 | active: action.isActive 31 | })) 32 | 33 | // Return null if all transformations are unavailable 34 | const allUnavailable = transformations.every((t) => t.disabled) 35 | 36 | return allUnavailable ? null : transformations 37 | } 38 | -------------------------------------------------------------------------------- /console/src/components/email_builder/ui/tiptap/index.ts: -------------------------------------------------------------------------------- 1 | // Main editor components 2 | export { TiptapRichEditor } from './TiptapRichEditor' 3 | export { TiptapInlineEditor } from './TiptapInlineEditor' 4 | 5 | // Toolbar components (can be used separately if needed) 6 | export { 7 | TiptapToolbar, 8 | ToolbarButton, 9 | ColorButton, 10 | ToolbarSeparator, 11 | EmojiButton, 12 | LinkButton 13 | } from './components/TiptapToolbar' 14 | 15 | // Shared types 16 | export type { 17 | BaseTiptapProps, 18 | TiptapRichEditorProps, 19 | TiptapInlineEditorProps, 20 | TiptapToolbarProps, 21 | ToolbarButtonProps, 22 | ColorButtonProps, 23 | EmojiButtonProps, 24 | LinkButtonProps, 25 | ButtonType, 26 | LinkType 27 | } from './shared/types' 28 | 29 | // Shared utilities (for advanced usage) 30 | export { 31 | expandSelectionToNode, 32 | applyFormattingWithNodeSelection, 33 | applyInlineFormatting, 34 | convertBlockToInline, 35 | processInlineContent, 36 | prepareInlineContent, 37 | getInitialInlineContent 38 | } from './shared/utils' 39 | 40 | // Shared extensions (for custom implementations) 41 | export { createRichExtensions, createInlineExtensions, InlineDocument } from './shared/extensions' 42 | 43 | // Styles utilities 44 | export { 45 | injectTiptapStyles, 46 | defaultToolbarStyle, 47 | defaultToolbarClasses, 48 | getToolbarButtonClasses, 49 | toolbarSeparatorClasses 50 | } from './shared/styles' 51 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/ui/ShortcutBadge.tsx: -------------------------------------------------------------------------------- 1 | import { Badge } from 'antd' 2 | import { parseShortcutKeys } from '../utils/shortcuts' 3 | 4 | /** 5 | * Props for the ShortcutBadge component 6 | */ 7 | export interface ShortcutBadgeProps { 8 | /** 9 | * The keyboard shortcut string to display (e.g., "mod+d", "ctrl+shift+k") 10 | * Will be parsed and formatted for the current platform 11 | */ 12 | shortcutKeys?: string 13 | } 14 | 15 | /** 16 | * ShortcutBadge - Displays keyboard shortcuts in a badge format 17 | * 18 | * Automatically formats shortcuts for the user's platform: 19 | * - On Mac: "mod+d" becomes "⌘ D" 20 | * - On Windows: "mod+d" becomes "Ctrl D" 21 | * 22 | * Uses Antd Badge component for consistent styling 23 | * 24 | * @example 25 | * 26 | * // Displays: ⌘ D (on Mac) or Ctrl D (on Windows) 27 | */ 28 | export function ShortcutBadge({ shortcutKeys }: ShortcutBadgeProps) { 29 | // Parse the shortcut keys into formatted symbols 30 | const formattedKeys = parseShortcutKeys({ shortcutKeys }) 31 | 32 | // If no keys to display, don't render anything 33 | if (formattedKeys.length === 0) { 34 | return null 35 | } 36 | 37 | // Join keys with space for display (e.g., ["⌘", "D"] -> "⌘ D") 38 | const displayText = formattedKeys.join(' ') 39 | 40 | return 41 | } 42 | 43 | -------------------------------------------------------------------------------- /console/src/components/email_builder/blocks/index.ts: -------------------------------------------------------------------------------- 1 | // Base classes and interfaces 2 | export { BaseEmailBlock } from './BaseEmailBlock' 3 | 4 | // Factory 5 | export { EmailBlockFactory } from './EmailBlockFactory' 6 | 7 | // Block implementations 8 | export { MjmlBlock } from './MjmlBlock' 9 | export { MjBodyBlock } from './MjBodyBlock' 10 | export { MjWrapperBlock } from './MjWrapperBlock' 11 | export { MjSectionBlock } from './MjSectionBlock' 12 | export { MjColumnBlock } from './MjColumnBlock' 13 | export { MjGroupBlock } from './MjGroupBlock' 14 | export { MjTextBlock } from './MjTextBlock' 15 | export { MjButtonBlock } from './MjButtonBlock' 16 | export { MjImageBlock } from './MjImageBlock' 17 | export { MjDividerBlock } from './MjDividerBlock' 18 | export { MjSpacerBlock } from './MjSpacerBlock' 19 | export { MjSocialBlock } from './MjSocialBlock' 20 | export { MjSocialElementBlock } from './MjSocialElementBlock' 21 | export { MjHeadBlock } from './MjHeadBlock' 22 | export { MjAttributesBlock } from './MjAttributesBlock' 23 | export { MjBreakpointBlock } from './MjBreakpointBlock' 24 | export { MjFontBlock } from './MjFontBlock' 25 | export { MjStyleBlock } from './MjStyleBlock' 26 | export { MjPreviewBlock } from './MjPreviewBlock' 27 | export { MjTitleBlock } from './MjTitleBlock' 28 | export { MjRawBlock } from './MjRawBlock' 29 | 30 | // TODO: Export other block types as they're implemented: 31 | // export { MjBodyBlock } from './MjBodyBlock' 32 | -------------------------------------------------------------------------------- /telemetry/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Deploy script for Notifuse Telemetry Google Cloud Function 4 | # Usage: ./deploy.sh [PROJECT_ID] [REGION] 5 | 6 | set -e 7 | 8 | # Default values 9 | PROJECT_ID=${1:-"notifusev3"} 10 | REGION=${2:-"europe-west1"} 11 | FUNCTION_NAME="notifuse-telemetry" 12 | ENTRY_POINT="ReceiveTelemetry" 13 | 14 | echo "Deploying Google Cloud Function..." 15 | echo "Project: $PROJECT_ID" 16 | echo "Region: $REGION" 17 | echo "Function Name: $FUNCTION_NAME" 18 | 19 | # Deploy the function 20 | gcloud functions deploy $FUNCTION_NAME \ 21 | --gen2 \ 22 | --runtime=go124 \ 23 | --region=$REGION \ 24 | --source=. \ 25 | --entry-point=$ENTRY_POINT \ 26 | --trigger-http \ 27 | --allow-unauthenticated \ 28 | --memory=256MB \ 29 | --timeout=30s \ 30 | --max-instances=10 \ 31 | --project=$PROJECT_ID \ 32 | --set-env-vars="GCP_PROJECT=$PROJECT_ID" 33 | 34 | echo "" 35 | echo "Deployment complete!" 36 | echo "" 37 | echo "Function URL:" 38 | gcloud functions describe $FUNCTION_NAME --region=$REGION --project=$PROJECT_ID --format="value(serviceConfig.uri)" 39 | 40 | echo "" 41 | echo "To test the function, you can use:" 42 | echo "curl -X POST \\" 43 | echo " \$(gcloud functions describe $FUNCTION_NAME --region=$REGION --project=$PROJECT_ID --format=\"value(serviceConfig.uri)\") \\" 44 | echo " -H \"Content-Type: application/json\" \\" 45 | echo " -d '{\"workspace_id_sha1\":\"test123\",\"contacts_count\":100}'" 46 | -------------------------------------------------------------------------------- /internal/repository/auth_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "time" 7 | 8 | "github.com/Notifuse/notifuse/internal/domain" 9 | ) 10 | 11 | // SQLAuthRepository is a SQL implementation of the AuthRepository interface 12 | type SQLAuthRepository struct { 13 | systemDB *sql.DB 14 | } 15 | 16 | // NewSQLAuthRepository creates a new SQLAuthRepository 17 | func NewSQLAuthRepository(db *sql.DB) *SQLAuthRepository { 18 | return &SQLAuthRepository{ 19 | systemDB: db, 20 | } 21 | } 22 | 23 | // GetSessionByID retrieves a session by ID and user ID 24 | func (r *SQLAuthRepository) GetSessionByID(ctx context.Context, sessionID string, userID string) (*time.Time, error) { 25 | var expiresAt time.Time 26 | err := r.systemDB.QueryRowContext(ctx, 27 | "SELECT expires_at FROM user_sessions WHERE id = $1 AND user_id = $2", 28 | sessionID, userID, 29 | ).Scan(&expiresAt) 30 | 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | return &expiresAt, nil 36 | } 37 | 38 | // GetUserByID retrieves a user by ID 39 | func (r *SQLAuthRepository) GetUserByID(ctx context.Context, userID string) (*domain.User, error) { 40 | var user domain.User 41 | err := r.systemDB.QueryRowContext(ctx, 42 | "SELECT id, email, created_at FROM users WHERE id = $1", 43 | userID, 44 | ).Scan(&user.ID, &user.Email, &user.CreatedAt) 45 | 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | return &user, nil 51 | } 52 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/components/colors/ColorConstants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Shared color palettes for text and background colors 3 | * Used across toolbar and block actions menu 4 | */ 5 | 6 | export interface ColorOption { 7 | label: string 8 | value: string | null 9 | } 10 | 11 | // Text color palette 12 | export const TEXT_COLORS: ColorOption[] = [ 13 | { label: 'Default', value: null }, 14 | { label: 'Gray', value: 'hsl(45, 2%, 46%)' }, 15 | { label: 'Brown', value: 'hsl(19, 31%, 47%)' }, 16 | { label: 'Orange', value: 'hsl(30, 89%, 45%)' }, 17 | { label: 'Yellow', value: 'hsl(38, 62%, 49%)' }, 18 | { label: 'Green', value: 'hsl(148, 32%, 39%)' }, 19 | { label: 'Blue', value: 'hsl(202, 54%, 43%)' }, 20 | { label: 'Purple', value: 'hsl(274, 32%, 54%)' }, 21 | { label: 'Pink', value: 'hsl(328, 49%, 53%)' }, 22 | { label: 'Red', value: 'hsl(2, 62%, 55%)' } 23 | ] 24 | 25 | // Background color palette 26 | export const BACKGROUND_COLORS: ColorOption[] = [ 27 | { label: 'Default', value: null }, 28 | { label: 'Gray', value: 'rgb(248, 248, 247)' }, 29 | { label: 'Brown', value: 'rgb(244, 238, 238)' }, 30 | { label: 'Orange', value: 'rgb(251, 236, 221)' }, 31 | { label: 'Yellow', value: '#fef9c3' }, 32 | { label: 'Green', value: '#dcfce7' }, 33 | { label: 'Blue', value: '#e0f2fe' }, 34 | { label: 'Purple', value: '#f3e8ff' }, 35 | { label: 'Pink', value: 'rgb(252, 241, 246)' }, 36 | { label: 'Red', value: '#ffe4e6' } 37 | ] 38 | -------------------------------------------------------------------------------- /internal/domain/auth.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | //go:generate mockgen -destination mocks/mock_auth_repository.go -package mocks github.com/Notifuse/notifuse/internal/domain AuthRepository 9 | //go:generate mockgen -destination mocks/mock_auth_service.go -package mocks github.com/Notifuse/notifuse/internal/domain AuthService 10 | 11 | type ContextKey string 12 | 13 | const SystemCallKey ContextKey = "system_call" 14 | 15 | // WorkspaceIDKey is the context key for workspace ID 16 | const WorkspaceIDKey ContextKey = "workspace_id" 17 | 18 | // AuthRepository defines the interface for auth-related database operations 19 | type AuthRepository interface { 20 | GetSessionByID(ctx context.Context, sessionID string, userID string) (*time.Time, error) 21 | GetUserByID(ctx context.Context, userID string) (*User, error) 22 | } 23 | 24 | type AuthService interface { 25 | AuthenticateUserFromContext(ctx context.Context) (*User, error) 26 | AuthenticateUserForWorkspace(ctx context.Context, workspaceID string) (context.Context, *User, *UserWorkspace, error) 27 | VerifyUserSession(ctx context.Context, userID, sessionID string) (*User, error) 28 | GenerateUserAuthToken(user *User, sessionID string, expiresAt time.Time) string 29 | GenerateAPIAuthToken(user *User) string 30 | GenerateInvitationToken(invitation *WorkspaceInvitation) string 31 | ValidateInvitationToken(token string) (invitationID, workspaceID, email string, err error) 32 | InvalidateSecretCache() 33 | } 34 | -------------------------------------------------------------------------------- /console/src/components/segment/messages.ts: -------------------------------------------------------------------------------- 1 | const Messages = { 2 | RequiredField: 'This field is required', 3 | ServiceAccountPasswordInvalidFormat: 'The password should contain 16 charaters minimum.', 4 | EmailRequired: 'Please enter your email.', 5 | YourNameIsRequired: 'Please enter your name.', 6 | PasswordRequired: 'Please input your password.', 7 | NewPasswordInvalid: 'Your new password should contain at least 8 characters.', 8 | ConfirmPasswordRequired: 'Please confirm your new password.', 9 | PasswordsDontMatch: 'The two passwords that you entered do not match!', 10 | InvalidTimezone: 'This time zone is not valid.', 11 | InvalidIdFormat: 'The value should-be-written-like-this', 12 | InvalidTableName: 'The value should_be_written_like_this', 13 | InvalidWorkspaceIdFormat: 'The ID should only contain alphanumeric characters (az09)', 14 | ValidURLRequired: 'A valid URL is required', 15 | InvalidArrayOfStrings: 'The value should be an array of strings.', 16 | InvalidPath: 'The path should be like: /url-path', 17 | InvalidURLParamsFormat: 18 | 'The parameter should only contain the following characters: A-Za-z0-9-_~', 19 | InvalidHostname: 'The value should be a valid host name.', 20 | InvalidFilterOperation: 'The operation is not valid.', 21 | InvalidTableColumName: 'The table column name should only contain the follow charats: a-Z0-9_-', 22 | InvalidOrganizationIDFormat: 'The ID should only contain alphanumeric characters (az09)' 23 | } 24 | 25 | export default Messages 26 | -------------------------------------------------------------------------------- /internal/domain/setting.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | //go:generate mockgen -destination mocks/mock_setting_repository.go -package mocks github.com/Notifuse/notifuse/internal/domain SettingRepository 9 | 10 | // Setting represents a system setting 11 | type Setting struct { 12 | Key string `json:"key"` 13 | Value string `json:"value"` 14 | CreatedAt time.Time `json:"created_at"` 15 | UpdatedAt time.Time `json:"updated_at"` 16 | } 17 | 18 | // SettingRepository defines the interface for setting-related database operations 19 | type SettingRepository interface { 20 | // Get retrieves a setting by key 21 | Get(ctx context.Context, key string) (*Setting, error) 22 | 23 | // Set creates or updates a setting 24 | Set(ctx context.Context, key, value string) error 25 | 26 | // Delete removes a setting by key 27 | Delete(ctx context.Context, key string) error 28 | 29 | // List retrieves all settings 30 | List(ctx context.Context) ([]*Setting, error) 31 | 32 | // SetLastCronRun updates the last cron execution timestamp 33 | SetLastCronRun(ctx context.Context) error 34 | 35 | // GetLastCronRun retrieves the last cron execution timestamp 36 | GetLastCronRun(ctx context.Context) (*time.Time, error) 37 | } 38 | 39 | // ErrSettingNotFound is returned when a setting is not found 40 | type ErrSettingNotFound struct { 41 | Key string 42 | } 43 | 44 | func (e *ErrSettingNotFound) Error() string { 45 | return "setting not found: " + e.Key 46 | } 47 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/menus/block-actions/ActionButton.tsx: -------------------------------------------------------------------------------- 1 | import type { MenuProps } from 'antd' 2 | import type { ActionItemConfig } from './block-actions-types' 3 | 4 | type MenuItem = Required['items'][number] 5 | 6 | /** 7 | * Extended action config with additional styling options 8 | */ 9 | export interface ExtendedActionItemConfig extends ActionItemConfig { 10 | iconStyle?: React.CSSProperties 11 | extra?: React.ReactNode 12 | } 13 | 14 | /** 15 | * Creates a menu item configuration for Antd Menu 16 | * Used to build menu items from action configurations 17 | */ 18 | export function createActionMenuItem({ 19 | icon: Icon, 20 | label, 21 | action, 22 | disabled = false, 23 | active = false, 24 | shortcut, 25 | iconStyle, 26 | extra 27 | }: ExtendedActionItemConfig): MenuItem { 28 | return { 29 | key: label, 30 | label: ( 31 |
32 | {Icon && ( 33 | 34 | 35 | 36 | )} 37 | 38 | {label} 39 | 40 | {extra && {extra}} 41 | {shortcut} 42 |
43 | ), 44 | onClick: action, 45 | disabled, 46 | className: active ? 'ant-menu-item-active' : undefined 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /console/src/components/email_builder/ui/InputLayout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Tooltip } from 'antd' 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 4 | import { faQuestionCircle } from '@fortawesome/free-solid-svg-icons' 5 | 6 | interface InputLayoutProps { 7 | label: React.ReactNode | string 8 | help?: string 9 | layout?: 'horizontal' | 'vertical' 10 | children: React.ReactNode 11 | className?: string 12 | } 13 | 14 | const InputLayout: React.FC = ({ 15 | label, 16 | help, 17 | children, 18 | className = 'mt-4', 19 | layout = 'horizontal' 20 | }) => { 21 | const flexDirection = layout === 'vertical' ? 'flex-col' : '' 22 | const alignmentClass = 'items-start' 23 | 24 | return ( 25 |
26 |
27 | {typeof label === 'string' ? ( 28 | 29 | ) : ( 30 | label 31 | )} 32 | {help && ( 33 | 34 | 38 | 39 | )} 40 |
41 |
{children}
42 |
43 | ) 44 | } 45 | 46 | export default InputLayout 47 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/components/colors/ColorPatch.tsx: -------------------------------------------------------------------------------- 1 | import type { ColorOption } from './ColorConstants' 2 | 3 | export interface ColorPatchProps { 4 | color: ColorOption 5 | type: 'text' | 'background' 6 | onClick: () => void 7 | } 8 | 9 | /** 10 | * ColorPatch - Reusable color swatch button 11 | * Used in both toolbar and block actions menu 12 | */ 13 | export function ColorPatch({ color, type, onClick }: ColorPatchProps) { 14 | return ( 15 | 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /internal/repository/testutil/db_test.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "database/sql" 5 | "testing" 6 | 7 | "github.com/DATA-DOG/go-sqlmock" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestSetupMockDB(t *testing.T) { 13 | t.Run("creates mock DB successfully", func(t *testing.T) { 14 | db, mock, cleanup := SetupMockDB(t) 15 | 16 | require.NotNil(t, db) 17 | require.NotNil(t, mock) 18 | require.NotNil(t, cleanup) 19 | 20 | // Verify db is a valid *sql.DB 21 | assert.IsType(t, (*sql.DB)(nil), db) 22 | 23 | // Verify mock is a valid sqlmock.Sqlmock 24 | assert.IsType(t, (*sqlmock.Sqlmock)(nil), &mock) 25 | 26 | // Cleanup should close the database 27 | cleanup() 28 | }) 29 | 30 | t.Run("cleanup closes database", func(t *testing.T) { 31 | db, _, cleanup := SetupMockDB(t) 32 | 33 | // Database should be open 34 | err := db.Ping() 35 | assert.NoError(t, err) 36 | 37 | // Call cleanup 38 | cleanup() 39 | 40 | // Database should be closed (ping should fail) 41 | err = db.Ping() 42 | assert.Error(t, err) 43 | }) 44 | 45 | t.Run("mock has QueryMatcherRegexp option", func(t *testing.T) { 46 | _, mock, cleanup := SetupMockDB(t) 47 | defer cleanup() 48 | 49 | // The mock should be configured with regexp matcher 50 | // We can verify this by setting up an expectation with regex 51 | mock.ExpectQuery("SELECT .* FROM users"). 52 | WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("1")) 53 | 54 | // The mock should accept regex patterns 55 | assert.NotNil(t, mock) 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/hooks/useEditor.ts: -------------------------------------------------------------------------------- 1 | import type { Editor } from '@tiptap/react' 2 | import { useCurrentEditor, useEditorState } from '@tiptap/react' 3 | import { useMemo } from 'react' 4 | 5 | /** 6 | * Hook that provides access to a Tiptap editor instance with reactive state tracking. 7 | * 8 | * Accepts an optional editor instance directly, or falls back to retrieving 9 | * the editor from the Tiptap context if available. This allows components 10 | * to work both when given an editor directly and when used within a Tiptap 11 | * editor context. 12 | * 13 | * @param providedEditor - Optional editor instance to use instead of the context editor 14 | * @returns The provided editor or the editor from context, whichever is available 15 | */ 16 | export function useNotifuseEditor(providedEditor?: Editor | null): { 17 | editor: Editor | null 18 | editorState?: Editor['state'] 19 | canCommand?: Editor['can'] 20 | } { 21 | const { editor: coreEditor } = useCurrentEditor() 22 | const mainEditor = useMemo(() => providedEditor || coreEditor, [providedEditor, coreEditor]) 23 | 24 | const editorState = useEditorState({ 25 | editor: mainEditor, 26 | selector(context) { 27 | if (!context.editor) { 28 | return { 29 | editor: null, 30 | editorState: undefined, 31 | canCommand: undefined 32 | } 33 | } 34 | 35 | return { 36 | editor: context.editor, 37 | editorState: context.editor.state, 38 | canCommand: context.editor.can 39 | } 40 | } 41 | }) 42 | 43 | return editorState || { editor: null } 44 | } 45 | -------------------------------------------------------------------------------- /console/src/components/segment/operator_array.tsx: -------------------------------------------------------------------------------- 1 | import { Form, Input } from 'antd' 2 | import Messages from './messages' 3 | import { DimensionFilter, IOperator } from '../../services/api/segment' 4 | 5 | // Operator for checking if a value is in a JSON array 6 | export class OperatorInArray implements IOperator { 7 | type: 'in_array' 8 | label: string 9 | 10 | constructor() { 11 | this.type = 'in_array' 12 | this.label = 'in array' 13 | } 14 | 15 | render(filter: DimensionFilter) { 16 | const value = filter.string_values && filter.string_values[0] 17 | return ( 18 | <> 19 | in array {value} 20 | 21 | ) 22 | } 23 | 24 | renderFormItems() { 25 | return ( 26 | 30 | 31 | {(fields, { add }) => { 32 | // Auto-initialize with one field if empty 33 | if (fields.length === 0) { 34 | add() 35 | } 36 | return ( 37 | <> 38 | {fields.slice(0, 1).map((field) => ( 39 | 40 | 41 | 42 | ))} 43 | 44 | ) 45 | }} 46 | 47 | 48 | ) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /notification_center/src/test/utils.tsx: -------------------------------------------------------------------------------- 1 | import { render, type RenderOptions } from '@testing-library/react' 2 | import { type ReactElement } from 'react' 3 | 4 | /** 5 | * Custom render function that wraps components with necessary providers 6 | */ 7 | export function renderWithProviders( 8 | ui: ReactElement, 9 | options?: Omit 10 | ) { 11 | return render(ui, { ...options }) 12 | } 13 | 14 | /** 15 | * Mock URL search parameters 16 | */ 17 | export function mockURLSearchParams(params: Record) { 18 | const searchParams = new URLSearchParams(params) 19 | delete (window as any).location 20 | ;(window as any).location = { 21 | search: `?${searchParams.toString()}`, 22 | href: `http://localhost:3001/?${searchParams.toString()}`, 23 | origin: 'http://localhost:3001', 24 | pathname: '/', 25 | } 26 | } 27 | 28 | /** 29 | * Common test data fixtures 30 | */ 31 | export const testData = { 32 | validParams: { 33 | wid: 'workspace-123', 34 | email: 'test@example.com', 35 | email_hmac: 'valid-hmac-123', 36 | lid: 'newsletter', 37 | lname: 'Newsletter', 38 | mid: 'message-123', 39 | }, 40 | previewParams: { 41 | wid: 'workspace-123', 42 | email: 'john.doe@example.com', 43 | email_hmac: 'abc123', 44 | mid: 'preview', 45 | }, 46 | contact: { 47 | id: 'contact-123', 48 | email: 'test@example.com', 49 | first_name: 'John', 50 | last_name: 'Doe', 51 | }, 52 | } 53 | 54 | /** 55 | * Wait for async operations to complete 56 | */ 57 | export const waitForAsync = () => new Promise(resolve => setTimeout(resolve, 0)) 58 | 59 | -------------------------------------------------------------------------------- /internal/migrations/interfaces.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/Notifuse/notifuse/config" 8 | "github.com/Notifuse/notifuse/internal/domain" 9 | ) 10 | 11 | // DBExecutor represents a database connection that can execute queries 12 | type DBExecutor interface { 13 | ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) 14 | QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) 15 | QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row 16 | } 17 | 18 | // MajorMigrationInterface defines a major version migration 19 | type MajorMigrationInterface interface { 20 | GetMajorVersion() float64 21 | HasSystemUpdate() bool 22 | HasWorkspaceUpdate() bool 23 | ShouldRestartServer() bool 24 | UpdateSystem(ctx context.Context, config *config.Config, db DBExecutor) error 25 | UpdateWorkspace(ctx context.Context, config *config.Config, workspace *domain.Workspace, db DBExecutor) error 26 | } 27 | 28 | // MigrationManager interface for managing migrations 29 | type MigrationManager interface { 30 | GetCurrentDBVersion(ctx context.Context, db *sql.DB) (float64, error, bool) 31 | SetCurrentDBVersion(ctx context.Context, db *sql.DB, version float64) error 32 | RunMigrations(ctx context.Context, config *config.Config, db *sql.DB) error 33 | } 34 | 35 | // MigrationRegistry manages registered migrations 36 | type MigrationRegistry interface { 37 | Register(migration MajorMigrationInterface) 38 | GetMigrations() []MajorMigrationInterface 39 | GetMigration(version float64) (MajorMigrationInterface, bool) 40 | } 41 | -------------------------------------------------------------------------------- /notification_center/src/test/mocks/api.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest' 2 | import type { 3 | ContactPreferencesResponse, 4 | SubscribeResponse, 5 | UnsubscribeResponse, 6 | } from '@/api/notification_center' 7 | 8 | export const mockContactPreferencesResponse: ContactPreferencesResponse = { 9 | contact: { 10 | id: 'contact-123', 11 | email: 'test@example.com', 12 | first_name: 'John', 13 | last_name: 'Doe', 14 | }, 15 | public_lists: [ 16 | { 17 | id: 'newsletter', 18 | name: 'Newsletter', 19 | description: 'Weekly newsletter', 20 | }, 21 | { 22 | id: 'announcements', 23 | name: 'Announcements', 24 | description: 'Important announcements', 25 | }, 26 | ], 27 | contact_lists: [ 28 | { 29 | email: 'test@example.com', 30 | list_id: 'newsletter', 31 | list_name: 'Newsletter', 32 | status: 'active', 33 | created_at: '2024-01-01T00:00:00Z', 34 | updated_at: '2024-01-01T00:00:00Z', 35 | }, 36 | ], 37 | logo_url: 'https://example.com/logo.png', 38 | website_url: 'https://example.com', 39 | } 40 | 41 | export const mockSubscribeResponse: SubscribeResponse = { 42 | success: true, 43 | } 44 | 45 | export const mockUnsubscribeResponse: UnsubscribeResponse = { 46 | success: true, 47 | } 48 | 49 | export const createMockApiModule = () => ({ 50 | getContactPreferences: vi.fn().mockResolvedValue(mockContactPreferencesResponse), 51 | subscribeToLists: vi.fn().mockResolvedValue(mockSubscribeResponse), 52 | unsubscribeOneClick: vi.fn().mockResolvedValue(mockUnsubscribeResponse), 53 | parseNotificationCenterParams: vi.fn(), 54 | }) 55 | 56 | -------------------------------------------------------------------------------- /internal/domain/mocks/mock_http_client.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/Notifuse/notifuse/internal/domain (interfaces: HTTPClient) 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | http "net/http" 9 | reflect "reflect" 10 | 11 | gomock "github.com/golang/mock/gomock" 12 | ) 13 | 14 | // MockHTTPClient is a mock of HTTPClient interface. 15 | type MockHTTPClient struct { 16 | ctrl *gomock.Controller 17 | recorder *MockHTTPClientMockRecorder 18 | } 19 | 20 | // MockHTTPClientMockRecorder is the mock recorder for MockHTTPClient. 21 | type MockHTTPClientMockRecorder struct { 22 | mock *MockHTTPClient 23 | } 24 | 25 | // NewMockHTTPClient creates a new mock instance. 26 | func NewMockHTTPClient(ctrl *gomock.Controller) *MockHTTPClient { 27 | mock := &MockHTTPClient{ctrl: ctrl} 28 | mock.recorder = &MockHTTPClientMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use. 33 | func (m *MockHTTPClient) EXPECT() *MockHTTPClientMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // Do mocks base method. 38 | func (m *MockHTTPClient) Do(arg0 *http.Request) (*http.Response, error) { 39 | m.ctrl.T.Helper() 40 | ret := m.ctrl.Call(m, "Do", arg0) 41 | ret0, _ := ret[0].(*http.Response) 42 | ret1, _ := ret[1].(error) 43 | return ret0, ret1 44 | } 45 | 46 | // Do indicates an expected call of Do. 47 | func (mr *MockHTTPClientMockRecorder) Do(arg0 interface{}) *gomock.Call { 48 | mr.mock.ctrl.T.Helper() 49 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Do", reflect.TypeOf((*MockHTTPClient)(nil).Do), arg0) 50 | } 51 | -------------------------------------------------------------------------------- /notification_center/src/api/client.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | API_ENDPOINT?: string 4 | } 5 | } 6 | 7 | class ApiError extends Error { 8 | constructor(message: string, public status: number, public data?: any) { 9 | super(message) 10 | this.name = 'ApiError' 11 | } 12 | } 13 | 14 | async function handleResponse(response: Response): Promise { 15 | if (!response.ok) { 16 | const errorData = await response.json().catch(() => null) 17 | 18 | throw new ApiError(errorData?.error || 'An error occurred', response.status, errorData) 19 | } 20 | return response.json() 21 | } 22 | 23 | async function request(endpoint: string, options: RequestInit = {}): Promise { 24 | const authToken = localStorage.getItem('auth_token') 25 | const headers = { 26 | 'Content-Type': 'application/json', 27 | ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), 28 | ...options.headers 29 | } 30 | 31 | const apiEndpoint = window.API_ENDPOINT || 'http://localhost:3000' 32 | const response = await fetch(`${apiEndpoint}${endpoint}`, { 33 | ...options, 34 | headers 35 | }) 36 | 37 | return handleResponse(response) 38 | } 39 | 40 | export const api = { 41 | get: (endpoint: string) => request(endpoint), 42 | post: (endpoint: string, data: any) => 43 | request(endpoint, { 44 | method: 'POST', 45 | body: JSON.stringify(data) 46 | }), 47 | put: (endpoint: string, data: any) => 48 | request(endpoint, { 49 | method: 'PUT', 50 | body: JSON.stringify(data) 51 | }), 52 | delete: (endpoint: string) => 53 | request(endpoint, { 54 | method: 'DELETE' 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /console/src/components/contacts/DeleteContactModal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal, Button } from 'antd' 2 | 3 | interface DeleteContactModalProps { 4 | visible: boolean 5 | onCancel: () => void 6 | onConfirm: () => void 7 | contactEmail: string 8 | loading?: boolean 9 | disabled?: boolean 10 | } 11 | 12 | export function DeleteContactModal({ 13 | visible, 14 | onCancel, 15 | onConfirm, 16 | contactEmail, 17 | loading = false, 18 | disabled = false 19 | }: DeleteContactModalProps) { 20 | return ( 21 | 27 | Cancel 28 | , 29 | 39 | ]} 40 | width={500} 41 | > 42 |
43 |

44 | Are you sure you want to delete {contactEmail}? 45 |

46 |
47 |

This will permanently remove the contact and their subscriptions.

48 |

49 | Message history and webhook events will be anonymized (email addresses redacted) but 50 | retained for analytics. 51 |

52 |

This action cannot be undone.

53 |
54 |
55 |
56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /pkg/templates/supabase.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/Notifuse/notifuse/pkg/notifuse_mjml" 8 | ) 9 | 10 | // parseEmailTreeJSON parses an email tree JSON string into an EmailBlock 11 | func parseEmailTreeJSON(jsonStr string) (notifuse_mjml.EmailBlock, error) { 12 | var rawData map[string]json.RawMessage 13 | if err := json.Unmarshal([]byte(jsonStr), &rawData); err != nil { 14 | return nil, fmt.Errorf("failed to parse JSON: %w", err) 15 | } 16 | 17 | // Extract the emailTree field 18 | emailTreeData, exists := rawData["emailTree"] 19 | if !exists { 20 | // If no emailTree wrapper, assume the entire JSON is the tree 21 | emailTreeData = []byte(jsonStr) 22 | } 23 | 24 | // Use the UnmarshalEmailBlock function from the notifuse_mjml package 25 | emailBlock, err := notifuse_mjml.UnmarshalEmailBlock(emailTreeData) 26 | if err != nil { 27 | return nil, fmt.Errorf("failed to unmarshal email block: %w", err) 28 | } 29 | 30 | return emailBlock, nil 31 | } 32 | 33 | // AllSupabaseTemplates returns a map of all Supabase template creation functions 34 | // This makes it easy to iterate over all templates in tests 35 | func AllSupabaseTemplates() map[string]func() (notifuse_mjml.EmailBlock, error) { 36 | return map[string]func() (notifuse_mjml.EmailBlock, error){ 37 | "signup": CreateSupabaseSignupEmailStructure, 38 | "magic_link": CreateSupabaseMagicLinkEmailStructure, 39 | "recovery": CreateSupabaseRecoveryEmailStructure, 40 | "email_change": CreateSupabaseEmailChangeEmailStructure, 41 | "invite": CreateSupabaseInviteEmailStructure, 42 | "reauthentication": CreateSupabaseReauthenticationEmailStructure, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/core/state/useControls.ts: -------------------------------------------------------------------------------- 1 | import type { Editor } from '@tiptap/react' 2 | import { useEditorState } from '@tiptap/react' 3 | import { INITIAL_EDITOR_CONTROLS, type EditorControls } from './EditorControls' 4 | 5 | /** 6 | * Hook to access editor controls state with automatic reactivity 7 | * 8 | * This hook uses Tiptap's useEditorState with a selector pattern to: 9 | * - Provide reactive access to editor controls state 10 | * - Prevent unnecessary re-renders by using a selector 11 | * - Handle null editor gracefully with fallback state 12 | * - Warn if extension is not properly configured 13 | * 14 | * @param editor - The Tiptap editor instance (can be null) 15 | * @returns EditorControls state object 16 | * 17 | * @example 18 | * ```tsx 19 | * const { isDragging, dragHandleLocked, activeMenuId } = useControls(editor) 20 | * 21 | * // Use in component 22 | *
23 | * {content} 24 | *
25 | * ``` 26 | */ 27 | export function useControls(editor: Editor | null): EditorControls { 28 | return ( 29 | useEditorState({ 30 | editor, 31 | selector: ({ editor }) => { 32 | if (!editor) return INITIAL_EDITOR_CONTROLS 33 | 34 | const controls = editor.storage.notifuseEditorControls 35 | if (!controls) { 36 | console.warn( 37 | 'ControlsExtension is not initialized. Ensure you have added ControlsExtension to your editor extensions.' 38 | ) 39 | return INITIAL_EDITOR_CONTROLS 40 | } 41 | 42 | return { ...INITIAL_EDITOR_CONTROLS, ...controls } 43 | } 44 | }) ?? INITIAL_EDITOR_CONTROLS 45 | ) 46 | } 47 | 48 | export default useControls 49 | -------------------------------------------------------------------------------- /console/src/layouts/MainLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Layout } from 'antd' 2 | import { ReactNode } from 'react' 3 | 4 | const { Content } = Layout 5 | 6 | interface MainLayoutProps { 7 | children: ReactNode 8 | } 9 | 10 | export function MainLayout({ children }: MainLayoutProps) { 11 | return ( 12 | 20 | {children} 21 | 31 | 32 | ) 33 | } 34 | 35 | interface MainLayoutSidebarProps { 36 | children: ReactNode 37 | title: string 38 | extra: ReactNode 39 | } 40 | 41 | export function MainLayoutSidebar({ children, title, extra }: MainLayoutSidebarProps) { 42 | return ( 43 |
44 |
52 |

{title}

53 | {extra} 54 |
55 | {children} 56 |
57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /notification_center/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notification_center", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview", 11 | "test": "vitest", 12 | "test:run": "vitest run", 13 | "test:ui": "vitest --ui", 14 | "test:coverage": "vitest run --coverage" 15 | }, 16 | "dependencies": { 17 | "@radix-ui/react-slot": "^1.2.3", 18 | "@shadcn/ui": "^0.0.4", 19 | "@tailwindcss/vite": "^4.1.12", 20 | "class-variance-authority": "^0.7.1", 21 | "clsx": "^2.1.1", 22 | "lucide-react": "^0.511.0", 23 | "next-themes": "^0.4.6", 24 | "react": "^19.1.0", 25 | "react-dom": "^19.1.0", 26 | "sonner": "^2.0.3", 27 | "tailwind-merge": "^3.3.0" 28 | }, 29 | "devDependencies": { 30 | "@eslint/js": "^9.25.0", 31 | "@testing-library/jest-dom": "^6.6.3", 32 | "@testing-library/react": "^16.1.0", 33 | "@testing-library/user-event": "^14.5.2", 34 | "@types/node": "^22.15.21", 35 | "@types/react": "^19.1.2", 36 | "@types/react-dom": "^19.1.2", 37 | "@vitejs/plugin-react": "^5.0.1", 38 | "@vitest/ui": "^2.1.9", 39 | "autoprefixer": "^10.4.21", 40 | "eslint": "^9.25.0", 41 | "eslint-plugin-react-hooks": "^5.2.0", 42 | "eslint-plugin-react-refresh": "^0.4.19", 43 | "globals": "^16.0.0", 44 | "happy-dom": "^20.0.2", 45 | "jsdom": "^25.0.1", 46 | "js-yaml": "^4.1.1", 47 | "postcss": "^8.5.3", 48 | "tailwindcss": "^4.1.6", 49 | "tw-animate-css": "^1.3.0", 50 | "typescript": "~5.8.3", 51 | "typescript-eslint": "^8.30.1", 52 | "vite": "^7.1.11", 53 | "vitest": "^2.1.9" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /.cursorrules_agent: -------------------------------------------------------------------------------- 1 | # Cursor Agent-Specific Rules 2 | 3 | ## Test Execution Protocol for Background Agents 4 | 5 | **CRITICAL: When running unit tests as a background agent, ALWAYS use the agent-optimized command:** 6 | 7 | ```bash 8 | make test-agent 9 | ``` 10 | 11 | **NEVER use:** 12 | - `make test-unit` (too verbose, causes hanging/crashes) 13 | - `go test -v ...` (verbose output overwhelms buffer) 14 | 15 | ### Why This Rule Exists 16 | 17 | The standard `test-unit` command uses `-v` (verbose) flag which outputs every single test case. With 190+ test files, this generates massive output that: 18 | 1. Overwhelms the agent's output buffer 19 | 2. Causes the agent to hang or crash 20 | 3. Makes it impossible to spot actual failures in the noise 21 | 22 | ### What `make test-agent` Does 23 | 24 | - ✅ Runs all unit tests with 5-minute timeout 25 | - ✅ Filters output to show ONLY failures and summaries 26 | - ✅ No verbose output (prevents buffer overflow) 27 | - ✅ Shows final test summary for quick overview 28 | - ✅ Clean, actionable output that won't crash the agent 29 | 30 | ### Usage Examples 31 | 32 | ```bash 33 | # Run all unit tests (agent-safe) 34 | make test-agent 35 | 36 | # Run specific layer tests (these are safe with -v since fewer tests) 37 | make test-domain 38 | make test-service 39 | make test-repo 40 | make test-http 41 | 42 | # Run with custom grep pattern if needed 43 | go test -timeout 5m ./internal/... 2>&1 | grep -E "FAIL|error" 44 | ``` 45 | 46 | ### Exception Cases 47 | 48 | You MAY use verbose tests for single layers when debugging: 49 | - `make test-domain` - Safe (domain tests only) 50 | - `make test-service` - Safe (service tests only) 51 | - `make test-repo` - Safe (repository tests only) 52 | 53 | But for ALL tests combined, ALWAYS use `make test-agent`. 54 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/hooks/useText.ts: -------------------------------------------------------------------------------- 1 | import type { Editor } from '@tiptap/react' 2 | 3 | /** 4 | * Checks if text/paragraph node transformation is available 5 | * 6 | * Validates: 7 | * - Editor is ready and editable 8 | * - Paragraph node type exists in schema 9 | * - Current selection can be converted to paragraph 10 | * 11 | * @param editor - The Tiptap editor instance 12 | * @returns true if paragraph toggle is available, false otherwise 13 | */ 14 | export function canToggleText(editor: Editor | null): boolean { 15 | if (!editor || !editor.isEditable) return false 16 | 17 | // Check if paragraph node exists in schema 18 | if (!editor.schema.nodes.paragraph) return false 19 | 20 | // Check if we can set the current block to paragraph 21 | return editor.can().setParagraph() 22 | } 23 | 24 | /** 25 | * Checks if the current block is a paragraph 26 | * 27 | * @param editor - The Tiptap editor instance 28 | * @returns true if current block is a paragraph, false otherwise 29 | */ 30 | export function isParagraphActive(editor: Editor | null): boolean { 31 | if (!editor) return false 32 | return editor.isActive('paragraph') 33 | } 34 | 35 | /** 36 | * Toggles the current block to/from a paragraph 37 | * 38 | * Converts the current block node to a paragraph (plain text block) 39 | * This is typically used to "reset" a heading or other block to plain text 40 | * 41 | * @param editor - The Tiptap editor instance 42 | * @returns true if toggle succeeded, false otherwise 43 | */ 44 | export function toggleParagraph(editor: Editor | null): boolean { 45 | if (!editor || !editor.isEditable) return false 46 | 47 | try { 48 | return editor.chain().focus().setParagraph().run() 49 | } catch { 50 | return false 51 | } 52 | } 53 | 54 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/menus/suggestion/configs/emoji-config.ts: -------------------------------------------------------------------------------- 1 | import type { Editor, Range } from '@tiptap/react' 2 | import { gitHubEmojis, type EmojiItem } from '@tiptap/extension-emoji' 3 | import { getFilteredEmojis } from '../../../utils/emoji' 4 | import type { SuggestionConfig, SuggestionItem } from '../types' 5 | 6 | /** 7 | * Emoji suggestion configuration 8 | * Triggered by ':' character 9 | */ 10 | 11 | // Filter out regional indicator emojis 12 | const availableEmojis = gitHubEmojis.filter((emoji) => !emoji.name.includes('regional')) 13 | 14 | /** 15 | * Convert EmojiItem to SuggestionItem 16 | */ 17 | const emojiToSuggestionItem = (emoji: EmojiItem): SuggestionItem => ({ 18 | id: emoji.name, 19 | label: emoji.name, 20 | subtext: emoji.shortcodes.join(', '), 21 | icon: emoji.emoji, 22 | keywords: [...emoji.shortcodes, ...emoji.tags], 23 | context: emoji 24 | }) 25 | 26 | /** 27 | * Emoji configuration for suggestion menu 28 | */ 29 | export const emojiConfig: SuggestionConfig = { 30 | char: ':', 31 | pluginKey: 'emoji-suggestion', 32 | 33 | // Get and filter emoji items 34 | getItems: async (query: string) => { 35 | // Use the existing filtering utility 36 | const filtered = getFilteredEmojis({ query, emojis: availableEmojis }) 37 | 38 | // Convert to our SuggestionItem format 39 | return filtered.map(emojiToSuggestionItem) 40 | }, 41 | 42 | // Handle emoji selection 43 | onSelect: (item: SuggestionItem, editor: Editor | null, range: Range) => { 44 | if (!editor || !item.context) return 45 | 46 | const emoji = item.context 47 | if (!emoji.emoji) return 48 | 49 | editor.chain().focus().deleteRange(range).insertContent(emoji.emoji).run() 50 | }, 51 | 52 | maxHeight: 384 53 | } 54 | -------------------------------------------------------------------------------- /pkg/smtp_relay/tls.go: -------------------------------------------------------------------------------- 1 | package smtp_relay 2 | 3 | import ( 4 | "crypto/tls" 5 | "encoding/base64" 6 | "fmt" 7 | 8 | "github.com/Notifuse/notifuse/pkg/logger" 9 | ) 10 | 11 | // TLSConfig holds configuration for TLS certificate management 12 | type TLSConfig struct { 13 | CertBase64 string // Base64 encoded certificate 14 | KeyBase64 string // Base64 encoded key 15 | Logger logger.Logger 16 | } 17 | 18 | // SetupTLS configures TLS from base64-encoded certificates 19 | // Returns a *tls.Config that can be used with the SMTP server 20 | func SetupTLS(cfg TLSConfig) (*tls.Config, error) { 21 | // Check if certificates are provided 22 | if cfg.CertBase64 == "" || cfg.KeyBase64 == "" { 23 | cfg.Logger.Warn("SMTP relay: No TLS configuration provided - server will run without TLS (NOT recommended for production)") 24 | return nil, nil 25 | } 26 | 27 | cfg.Logger.Info("SMTP relay: Configuring TLS from base64-encoded certificates") 28 | 29 | // Decode certificate 30 | certPEM, err := base64.StdEncoding.DecodeString(cfg.CertBase64) 31 | if err != nil { 32 | return nil, fmt.Errorf("failed to decode base64 certificate: %w", err) 33 | } 34 | 35 | // Decode key 36 | keyPEM, err := base64.StdEncoding.DecodeString(cfg.KeyBase64) 37 | if err != nil { 38 | return nil, fmt.Errorf("failed to decode base64 key: %w", err) 39 | } 40 | 41 | // Load certificate and key 42 | cert, err := tls.X509KeyPair(certPEM, keyPEM) 43 | if err != nil { 44 | return nil, fmt.Errorf("failed to load TLS certificate from base64: %w", err) 45 | } 46 | 47 | tlsConfig := &tls.Config{ 48 | Certificates: []tls.Certificate{cert}, 49 | MinVersion: tls.VersionTLS12, 50 | } 51 | 52 | cfg.Logger.Info("SMTP relay: TLS configured successfully from base64 certificates") 53 | 54 | return tlsConfig, nil 55 | } 56 | -------------------------------------------------------------------------------- /console/certificates/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCT3ODyw31ggD9+ 3 | W5DE+4ZXy9ekJGcGLzUbI8EjUGZCHyrfTwB+xJQNVoyfy7klxg8repqZEqFtqFWz 4 | VLwUAkrl0hlcbrWI200i7LhbNiT3TWL44G98mn1yHZFUSkkqvU6vofOwMKvlVYM/ 5 | bey2IdZYUJq+nRRupKWETZPPgtJtzhzRWqrXbkRLdEfmlRrqOb8Uv3CsiWAM5sKv 6 | eOQgOFb/CDsuUIwVHRSTws4gScSyZEivWtitPsesJ9I18YJwO4elM5I9snLFnngA 7 | wCEgJOX1zhjbavT+O+tXobBLkkjBDZr7ASAQkSbsO2FM1p+r1HFbqV6NIU4MQ19D 8 | YwEGtBz1AgMBAAECggEAAIj/s4k6phOf3InMIl19PQNGcFSsZchcPBg3gW2FF+Sr 9 | 0Wmyl1gyoGxJhLqmY9A/ck7BUVHJzbnhxU0GkgOiql8bJ6t1rz1QLKELweD53xuY 10 | 9IVcie9+pnVG4csT0Db7IXPgvcUzU2a+sNVwtdrR/zIbtbafqSswGvKsYAd5xBs+ 11 | wrConmQvE2+d/rKQdPzs6FStLZkbJWr4oZBM+LZLP+368+Z3gopOC7U6SipIU5Li 12 | i4T4yL1DWwMbD54ZRHR8tf7MFBE3U6diWMBrzmN9WPY7IkAWU7N5jaLfk9XYSyIr 13 | qM/Ti1STfTEwQkHaXK6al/uaBv04dCIhkqs/LJxf6QKBgQDNMu3bp3oPS8k2Nvbl 14 | jWMZnOoxlz9zTHTd50/ExSUNgm9vqqo8S+xpOFy6/L9pmYD0u4qk8dmMoXwYBBim 15 | VXIjzTNM+88M6RxytlsWnxvw0/h0kiR8AStkEVPnpClPuFVc9EMSzBUHbSUbadKm 16 | h8oiWnjqrizONGyzaCivx5E4LQKBgQC4eBuW+wrRPtZ3AkTpL9C7rToPVA9o7vec 17 | rA2QETAMmH/6gIdRuhmkTENzR9u7GUJqoXCZBEk3gbvOwond0MLLkFcDauILEnRK 18 | CcxhQwvwxwex+jBp7VvsdR874c4bQ8Jjo/GAyqLAnqTw86dbG59m3XtkVUnxxnp/ 19 | 8QIQZ0ls6QKBgGKyVlnKShlFWHhtI3/x63KMWNCVcP1iDuwUr/dy44mF7VeGfO8X 20 | jZRmeaOuodqG7NHJyrvfX8YWffuHLNwESSwTLNOgYkxRa095ioJs8SF6swxOpqHG 21 | ZjpxYywNd5lSjixxiDloU80IoEp5McrLkVvIrFQkhoSADrCULs2tbZnZAoGAJ7DI 22 | Fus6/5yqnn6hfx7npYn3JRcsHaLVKiOm42mfUgZ5+tcuxnnpTH2QQbyjXZVowfKs 23 | fG/8pPHIDAu1iEGDuDL9VeHocwNsfAWxsPexGQRp9nRjeFc24SCuML88Doe8yp2a 24 | t+BhtlosGSAD0UmXOZXMF+F2AIx5DRA5JnhixwkCgYBbyJnI4MXc65jcElUrGfsI 25 | IR3iA1Go337QWQRxIcXPgXsYln1HRhv8o1f5QgdfJDZgqlh2AVcss6d7iTg7OS6U 26 | fdQ6O0dPtVEqXGFmVPhOFmaZOf11hKa5HPtOWpAkL9X03zwOjUjzv7f78hVFga3g 27 | dUpS9o7NwKXyVS1ahg6UFQ== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /tests/testdata/certs/test_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCeTZ0Sqa5HNp7w 3 | 4iotHqdqM/1rkR/cRxqNTAsDeQb8MPLwk34CXV6igE52pHXWeU9lZokGHCgxc67V 4 | gAN2Vd/zx7dbzqW2vut0ohZxYHaC6gakwFdBjLRYF6Esc7EU42kwLttDe8nNefVR 5 | lxXIyBpDzdr2MkGIrtzhsKO4CS1mJM+0wdhNJl2txTcJIkQmrbhZaTCLuEvVGEvQ 6 | RTBtojpyowr4ljGD8bFTwU6RUGCadmeyMj3Nh3v+raMBW45ydCG4mr+Mw9lP5VZI 7 | 961Sc9NT4YggpsnjmD7z1ltVjAb4m+IOK9IHQFAb9WtyeRi5QrVz8dv5kdn9ivaT 8 | 03y2iAGJAgMBAAECggEAAgRo7J6VKhLl/FmTckL+XOX39B2Tr3vH+LY3+zW4+zB3 9 | /g0RGWBddvpl1ZDzr/WYh4kilJ15/SRVXWLd2G2QdqOsQFseJTmiWDiDCvOQ2wr2 10 | pfWN/xe3ChrV+tIoFuUtd1fXgzECAbBsyfsAA/CZ1hSa1lGOYHqi9azJr0wNpCwt 11 | iG9JTW2OwylhcUo3JPhqy6AUmBGiT6sE2jqyRCpoH2LfddIhVeSGw9hjQuSCsqhY 12 | 9X9rotui7xhTWD3RoFA9EUXqq6Os62JbzDQEx52OEGG6flJOu/J9d+wr8aRX4g4L 13 | c2SstOf/kzBpqRv2mkqYtKbCkEEduK0OBCqG8b13kQKBgQDK4LWfNZq2ZCBA91yv 14 | 0B5fRyzmtZzeUJzjnqo0S+LXlPQVgLr+oW55p046plhCZogjJxNoGJiVehwrHxOB 15 | K8T/X4KkzYs/gTTd4LSgeKiBDFNxPySV+Z3FWtAoCk7d6RblRwIg9bll03/nWYBb 16 | EACAEF/PsRraFD2+J/Jh8soT2QKBgQDHwPuPDKEq/qa67m7PZ77lL4xX5Db6AyKy 17 | CEi35GLp/7/J/rTfHZBjgo9S03lMGBkl0mPNxC06+b2XIWrFFV9a5riOwWoDM2rx 18 | 2u0b6CMqwVC+KQgus4l2wC4hQ97aYzBhYXfAmcKLC2xAOvWv51lG0vhntFm7gGFH 19 | OI8IXeu9MQKBgQChNfB8o87dsjCDD0zClCEeWuOOLLCLZAlXQmRDjC2kW0Odtp5g 20 | 6gxsdQrPxhEKKolxTYK5TBorZU3u4hHQqeQvfUjGBmLpQpWs3fsKLPbRHOdNbPx4 21 | hFLfWSthNde3tJmx9Tv2zuvUwzy2rMM3GT8chGZuFnCc7EqnyPxs4s26+QKBgCw8 22 | 7vlFQMQ21VH80Ama3kn/d8NXRV2lKB/pecFQER/lheIESKZI89s15Ovg7bIOfDNG 23 | HthJJAM1n+lCe1TeYNnO0vy6lPHUh1C8vVo61N75JRqYF7nQBReJhC3VzBrtcJ+A 24 | aHb9FnqYswaeiB7Gy5zFyEGfTWgbDHArdHHT6wthAoGBAMRkh7wOfR1hnznUcv0Q 25 | TB0BvraBr7/1gAA9xr6wICNq8NXQuDLBS6Zu+YwaTaNFjxG2E0KCvfzdjgcqWLhY 26 | ZEGf0+WksETTUfnNKNJ8ytW5jvNLfyOQ1yNEAR3TM6b69bzvw9Yco/QZit5HUHcu 27 | yMJCJU481UL/lDa39n2YwSD5 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/menus/block-actions/TransformOptionsGroup.tsx: -------------------------------------------------------------------------------- 1 | import type { MenuProps } from 'antd' 2 | import { 3 | canResetFormatting, 4 | resetFormatting 5 | } from '../../hooks/useResetAllFormatting' 6 | import { RotateCcw } from 'lucide-react' 7 | import { createActionMenuItem } from './ActionButton' 8 | import { useNotifuseEditor } from '../../hooks/useEditor' 9 | import { useBlockTransformPopover } from './BlockTransformPopover' 10 | import { useBlockColorPopover } from './BlockColorPopover' 11 | 12 | /** 13 | * TransformOptionsGroup - Block transformation and formatting reset actions 14 | * Returns menu items configuration for Antd Menu 15 | */ 16 | export function useTransformOptionsGroup(onCloseMenu: () => void): MenuProps['items'] { 17 | const { editor } = useNotifuseEditor() 18 | const transformPopover = useBlockTransformPopover(onCloseMenu) 19 | const colorPopover = useBlockColorPopover(onCloseMenu) 20 | 21 | const preserveMarks = ['inlineThread'] 22 | const canReset = canResetFormatting(editor, preserveMarks) 23 | 24 | const handleResetFormatting = () => resetFormatting(editor, preserveMarks) 25 | 26 | if (!transformPopover && !colorPopover && !canReset) return [] 27 | 28 | const items: MenuProps['items'] = [] 29 | 30 | // Add Turn Into popover 31 | if (transformPopover) { 32 | items.push(transformPopover) 33 | } 34 | 35 | // Add Color popover 36 | if (colorPopover) { 37 | items.push(colorPopover) 38 | } 39 | 40 | if (canReset) { 41 | items.push( 42 | createActionMenuItem({ 43 | icon: RotateCcw, 44 | label: 'Reset formatting', 45 | action: handleResetFormatting, 46 | disabled: !canReset 47 | }) 48 | ) 49 | } 50 | 51 | items.push({ type: 'divider', key: 'transform-divider' }) 52 | 53 | return items 54 | } 55 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/core/registry/action-specs/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Action Specs Registry Initialization 3 | * 4 | * This file automatically registers all action definitions with the 5 | * ActionRegistry when imported. Import this file in your editor 6 | * setup to make all actions available throughout the application. 7 | */ 8 | 9 | import { notifuseActionRegistry } from '../ActionRegistry' 10 | 11 | // Import all action specification modules 12 | import { blockOperationSpecs } from './block-ops' 13 | import { nodeTransformSpecs } from './node-transforms' 14 | import { textMarkSpecs } from './text-marks' 15 | import { textAlignmentSpecs } from './text-alignment' 16 | import { linkColorSpecs } from './link-color-actions' 17 | 18 | /** 19 | * Register all action definitions with the registry 20 | */ 21 | notifuseActionRegistry.registerMany([ 22 | ...blockOperationSpecs, 23 | ...nodeTransformSpecs, 24 | ...textMarkSpecs, 25 | ...textAlignmentSpecs, 26 | ...linkColorSpecs 27 | ]) 28 | 29 | /** 30 | * Re-export all action specs and individual actions for direct access 31 | */ 32 | export * from './block-ops' 33 | export * from './node-transforms' 34 | export * from './text-marks' 35 | export * from './text-alignment' 36 | export * from './link-color-actions' 37 | 38 | /** 39 | * Export the registry instance for direct access 40 | */ 41 | export { notifuseActionRegistry } from '../ActionRegistry' 42 | 43 | /** 44 | * Export consumer hooks 45 | */ 46 | export { useAction } from '../useAction' 47 | export { useActions, useActionsArray } from '../useActions' 48 | 49 | /** 50 | * Export types 51 | */ 52 | export type { ActionDefinition, ActionType } from '../ActionRegistry' 53 | export type { ActionState, UseActionConfig } from '../useAction' 54 | export type { BatchActionState, UseActionsConfig } from '../useActions' 55 | -------------------------------------------------------------------------------- /console/src/services/api/webhook_registration.ts: -------------------------------------------------------------------------------- 1 | import { api } from './client' 2 | import type { EmailProviderKind } from './workspace' 3 | 4 | export type EmailEventType = 'delivered' | 'bounce' | 'complaint' | 'click' | 'open' 5 | 6 | export interface WebhookEndpointStatus { 7 | webhook_id: string 8 | url: string 9 | event_type: EmailEventType 10 | active: boolean 11 | } 12 | 13 | export interface WebhookRegistrationStatus { 14 | email_provider_kind: EmailProviderKind 15 | is_registered: boolean 16 | endpoints?: WebhookEndpointStatus[] 17 | error?: string 18 | provider_details?: Record 19 | } 20 | 21 | export interface RegisterWebhookRequest { 22 | workspace_id: string 23 | integration_id: string 24 | base_url: string 25 | event_types?: EmailEventType[] 26 | } 27 | 28 | export interface RegisterWebhookResponse { 29 | status: WebhookRegistrationStatus 30 | } 31 | 32 | export interface GetWebhookStatusRequest { 33 | workspace_id: string 34 | integration_id: string 35 | } 36 | 37 | export interface GetWebhookStatusResponse { 38 | status: WebhookRegistrationStatus 39 | } 40 | 41 | /** 42 | * Register webhooks for an email provider integration 43 | */ 44 | export async function registerWebhook( 45 | request: RegisterWebhookRequest 46 | ): Promise { 47 | return api.post('/api/webhooks.register', request) 48 | } 49 | 50 | /** 51 | * Get the current status of webhooks for an email provider integration 52 | */ 53 | export async function getWebhookStatus( 54 | request: GetWebhookStatusRequest 55 | ): Promise { 56 | const { workspace_id, integration_id } = request 57 | return api.get( 58 | `/api/webhooks.status?workspace_id=${workspace_id}&integration_id=${integration_id}` 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /console/src/components/email_builder/blocks/MjHeadBlock.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import type { MJMLComponentType } from '../types' 3 | import { 4 | BaseEmailBlock 5 | } from './BaseEmailBlock' 6 | import { MJML_COMPONENT_DEFAULTS } from '../mjml-defaults' 7 | import PanelLayout from '../panels/PanelLayout' 8 | 9 | /** 10 | * Implementation for mj-head blocks 11 | */ 12 | export class MjHeadBlock extends BaseEmailBlock { 13 | getIcon(): React.ReactNode { 14 | return null 15 | } 16 | 17 | getLabel(): string { 18 | return 'Head' 19 | } 20 | 21 | getDescription(): React.ReactNode { 22 | return 'Contains metadata and configuration for the email' 23 | } 24 | 25 | getCategory(): 'content' | 'layout' { 26 | return 'layout' 27 | } 28 | 29 | getDefaults(): Record { 30 | return MJML_COMPONENT_DEFAULTS['mj-head'] || {} 31 | } 32 | 33 | canHaveChildren(): boolean { 34 | return true 35 | } 36 | 37 | getValidChildTypes(): MJMLComponentType[] { 38 | return [ 39 | 'mj-attributes', 40 | 'mj-breakpoint', 41 | 'mj-font', 42 | 'mj-html-attributes', 43 | 'mj-preview', 44 | 'mj-style', 45 | 'mj-title' 46 | ] 47 | } 48 | 49 | /** 50 | * Render the settings panel for the head block 51 | */ 52 | renderSettingsPanel(): React.ReactNode { 53 | return ( 54 | 55 |
56 | No settings available for the head element. 57 |
58 | Add child elements like mj-font, mj-style, or mj-preview to configure email metadata. 59 |
60 |
61 | ) 62 | } 63 | 64 | getEdit(): React.ReactNode { 65 | // Head blocks don't render in preview (they contain metadata) 66 | return null 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/components/InsertBlockButton.tsx: -------------------------------------------------------------------------------- 1 | import type { Editor } from '@tiptap/react' 2 | import type { Node } from '@tiptap/pm/model' 3 | import { Button, Tooltip } from 'antd' 4 | import { Plus } from 'lucide-react' 5 | 6 | // Hooks 7 | import { useInsertBlock } from '../hooks/useInsertBlock' 8 | 9 | export interface InsertBlockButtonProps { 10 | /** 11 | * The Tiptap editor instance (optional, can use context) 12 | */ 13 | editor?: Editor | null 14 | /** 15 | * The node to insert after 16 | */ 17 | node?: Node | null 18 | /** 19 | * The position of the node in the document 20 | */ 21 | nodePos?: number | null 22 | } 23 | 24 | /** 25 | * Button component for inserting a block (triggers slash menu) 26 | * Used in the BlockActionsMenu to the left of the drag handle 27 | */ 28 | export function InsertBlockButton({ editor, node, nodePos }: InsertBlockButtonProps) { 29 | const { isVisible, handleInsertBlock, canInsert, label } = useInsertBlock({ 30 | editor, 31 | node, 32 | nodePos 33 | }) 34 | 35 | if (!isVisible) { 36 | return null 37 | } 38 | 39 | const handleClick = (e: React.MouseEvent) => { 40 | e.preventDefault() 41 | e.stopPropagation() 42 | handleInsertBlock() 43 | } 44 | 45 | return ( 46 | 47 | 65 | 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /console/src/components/email_builder/ui/WidthPxInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react' 2 | import { InputNumber } from 'antd' 3 | 4 | interface WidthPxInputProps { 5 | value?: string 6 | onChange?: (value: string | undefined) => void 7 | placeholder?: string 8 | defaultValue?: string 9 | min?: number 10 | max?: number 11 | step?: number 12 | disabled?: boolean 13 | size?: 'small' | 'middle' | 'large' 14 | style?: React.CSSProperties 15 | } 16 | 17 | const WidthPxInput: React.FC = memo( 18 | ({ 19 | value, 20 | onChange, 21 | placeholder, 22 | defaultValue, 23 | min = 0, 24 | max = 2000, 25 | step = 1, 26 | disabled = false, 27 | size = 'small', 28 | style = { width: '100px' } 29 | }) => { 30 | /** 31 | * Parse border radius to get numeric value 32 | */ 33 | const parseWidthPxNumber = (WidthPx?: string): number | undefined => { 34 | if (!WidthPx) return undefined 35 | const match = WidthPx.match(/^(\d+(?:\.\d+)?)px?$/) 36 | return match ? parseFloat(match[1]) : undefined 37 | } 38 | 39 | const handleChange = (numValue: number | null) => { 40 | const formattedValue = 41 | numValue !== null && numValue !== undefined ? `${numValue}px` : undefined 42 | onChange?.(formattedValue) 43 | } 44 | 45 | const parsedValue = parseWidthPxNumber(value) 46 | const parsedPlaceholder = placeholder || (parseWidthPxNumber(defaultValue) || 0).toString() 47 | 48 | return ( 49 | 61 | ) 62 | } 63 | ) 64 | 65 | export default WidthPxInput 66 | -------------------------------------------------------------------------------- /console/vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vitest/config' 3 | import react from '@vitejs/plugin-react' 4 | import tailwindcss from '@tailwindcss/vite' 5 | import { fileURLToPath } from 'url' 6 | import { dirname, resolve } from 'path' 7 | import { readFileSync } from 'fs' 8 | import path from 'path' 9 | 10 | const __filename = fileURLToPath(import.meta.url) 11 | const __dirname = dirname(__filename) 12 | 13 | // https://vitejs.dev/config/ 14 | export default defineConfig({ 15 | base: '/console/', 16 | plugins: [react(), tailwindcss()], 17 | server: { 18 | host: 'notifusedev.com', 19 | https: { 20 | key: readFileSync(resolve(__dirname, 'certificates/key.pem')), 21 | cert: readFileSync(resolve(__dirname, 'certificates/cert.pem')) 22 | }, 23 | proxy: { 24 | '/config.js': { 25 | target: 'https://localapi.notifuse.com:4000', 26 | changeOrigin: true, 27 | secure: false, 28 | rewrite: (path) => path.replace(/^\/console/, '') 29 | }, 30 | '/console/config.js': { 31 | target: 'https://localapi.notifuse.com:4000', 32 | changeOrigin: true, 33 | secure: false, 34 | rewrite: (path) => path.replace(/^\/console/, '') 35 | } 36 | } 37 | }, 38 | test: { 39 | globals: true, 40 | environment: 'jsdom', 41 | setupFiles: ['./src/test/setup.tsx'], 42 | include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 43 | coverage: { 44 | reporter: ['text', 'json', 'html'], 45 | exclude: ['node_modules/', 'src/test/setup.tsx'] 46 | } 47 | }, 48 | resolve: { 49 | alias: { 50 | '@': path.resolve(__dirname, './src') 51 | }, 52 | extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'] 53 | }, 54 | optimizeDeps: { 55 | include: ['@fortawesome/react-fontawesome', '@fortawesome/fontawesome-svg-core'] 56 | } 57 | }) 58 | -------------------------------------------------------------------------------- /internal/migrations/version.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/Notifuse/notifuse/config" 9 | ) 10 | 11 | // ParseVersion parses version string like "v3.14" or "3.14" and returns major version 12 | func ParseVersion(versionStr string) (float64, error) { 13 | // Remove 'v' prefix if present 14 | cleanVersion := strings.TrimPrefix(versionStr, "v") 15 | 16 | // Split by dot to get major.minor 17 | parts := strings.Split(cleanVersion, ".") 18 | if len(parts) == 0 { 19 | return 0, fmt.Errorf("invalid version format: %s", versionStr) 20 | } 21 | 22 | // Parse major version 23 | major, err := strconv.ParseFloat(parts[0], 64) 24 | if err != nil { 25 | return 0, fmt.Errorf("invalid major version: %s", parts[0]) 26 | } 27 | 28 | return major, nil 29 | } 30 | 31 | // GetCurrentCodeVersion returns the major version from config.VERSION 32 | func GetCurrentCodeVersion() (float64, error) { 33 | return ParseVersion(config.VERSION) 34 | } 35 | 36 | // CompareVersions compares two version strings 37 | // Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2 38 | func CompareVersions(v1, v2 string) (int, error) { 39 | major1, err := ParseVersion(v1) 40 | if err != nil { 41 | return 0, fmt.Errorf("failed to parse version %s: %w", v1, err) 42 | } 43 | 44 | major2, err := ParseVersion(v2) 45 | if err != nil { 46 | return 0, fmt.Errorf("failed to parse version %s: %w", v2, err) 47 | } 48 | 49 | if major1 < major2 { 50 | return -1, nil 51 | } else if major1 > major2 { 52 | return 1, nil 53 | } 54 | return 0, nil 55 | } 56 | 57 | // IsVersionSuperior checks if newVersion is superior to currentVersion 58 | func IsVersionSuperior(currentVersion, newVersion string) (bool, error) { 59 | comparison, err := CompareVersions(currentVersion, newVersion) 60 | if err != nil { 61 | return false, err 62 | } 63 | return comparison < 0, nil 64 | } 65 | -------------------------------------------------------------------------------- /certs/localhost.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFCTCCAvGgAwIBAgIUVByG5EOASBUu5LUuEuMaols0/PgwDQYJKoZIhvcNAQEL 3 | BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI1MDMxNzA5MjEyMloXDTI2MDMx 4 | NzA5MjEyMlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIICIjANBgkqhkiG9w0BAQEF 5 | AAOCAg8AMIICCgKCAgEA9H3I4E8fs8oHoiDZx9umbjamPXwaRYU6l2ZfW6/lnQsG 6 | JS+++eMxUVmRqh+zgmA4nuz4YSO9j+3s4Mi4dwyQJ5yd1IGmowKkRemqLl5+Pk+E 7 | DUxuTKE9PYdSKzJFmDgkYqZoTitgVC7la1m18LtGrNDHIguWYqoMwo2N2+h+sHLc 8 | aWTHQtbYgPB1H71udiBn7kRaHy2q94Un3qFJ449PH6BeZ94bswJaT5DnvVze4TkQ 9 | o31gzscNBveI3XxJCDRVnZP2zN3ymle0ptEdbJyIlCb1msum0bp0NtxDEsoEdja3 10 | nxZ4NhyT84hc6Vu0oLQQUoj3hf6SyIdH+3QF2zeYLkOGMe0Zh2Cd0ecc6a7isMhb 11 | 5Rzv19iNhcn5XNUIdAMbUzx067ruuV+jF2pPSKwhgwBgLwPqUw/1RkdzNgIkqObP 12 | N6dWLzg1ENrB9IxuEzKebjfdUyWaP5BDdSNHIz/yZtFPnLMH7iUwIv9rSlIjWBQS 13 | WvPtxGiioAHyg07xOtDtTaVeW1u0YSnOrOPB4v7Crq8bNrQwdKO7rMIxurNRkgPL 14 | 9fu7mNtAocK7QpxgycYZVgWi9bESPJBxdi+5eQkPRlLJf8VkYyKFAzxIgvxsBoV3 15 | k1YJTArPENTxyvp+tRMdk77Q08kesPPaabRJdii9r+zEdcB2FAXpPTUrEp7j600C 16 | AwEAAaNTMFEwHQYDVR0OBBYEFNqJF2wzkGfZcLzivbu5iBnw3v9nMB8GA1UdIwQY 17 | MBaAFNqJF2wzkGfZcLzivbu5iBnw3v9nMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI 18 | hvcNAQELBQADggIBACgTzrisnYWyb5rRG2NePqn6rPBjTiRS74cTdyqQud13VhWq 19 | lt4rvoMN/lDen4WRlDNROzx09RwIGeWfVd8hO2XgNS+o7Igny5peJJDILqAsdV1h 20 | ecPgR0vSJ0rsMjOAwrn5PSzcybpJO9/C3m9hYKayMqpO6pb3NHbycD/ZML37Ra6d 21 | q5iD1rfy358+bQD1nFpOv0A8Dq4DjHxLUeu2yKXMk7fHYHnAON28PuMXJMsLCTuq 22 | rk7k0nahwxHgywlVy7Xr1XhVCa6o6jNdU/37qHALuB9vrrm9wvBqMv0YymW9L7te 23 | ARDASWtV/3+S6AVFEY0eoj4pUuz4Gt+ziKEIhAvLF5dVYXAQw3CUnVQYgKbrVcaQ 24 | 2u7PnHeYDEGSTDde1yNLV8sZy1hjbSqUgumFo7ODosYqYsrR7OeU3v58EpMMdCcp 25 | T9aGDjPxHsQie1uKtDPm49eBrVv//MXIXmFrolEjSKw7nCBaLexHJcoRNmOu7NE6 26 | COZSVNfZF2GdK2/7QYXS4deZya3i+N8RyabnYGW/6HxbCWeNyX/53PPsgm7WFyHp 27 | NRRWTFvJLH9kMKfTZV21ZGqTQvL6DIzzwaAgDKHfOKYAyVlYwIQ18aJxzRzYaFXo 28 | bp4BcB9hTandmzJ6n5yU39Z8b2MXi77ilAJPt0OAlzYmS7pQhgblTeeYZ54h 29 | -----END CERTIFICATE----- 30 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/toolbars/SelectionToolbar.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { EditorContext } from '@tiptap/react' 3 | import { FloatingToolbar } from './FloatingToolbar' 4 | import { ToolbarButton } from './ToolbarButton' 5 | import { ToolbarSection } from './ToolbarSection' 6 | import { TurnIntoDropdown, LinkPopover, ColorPicker, MoreMenu } from './components' 7 | import { useFloatingToolbar } from './useFloatingToolbar' 8 | import { useControls } from '../core/state/useControls' 9 | import { CENTER_SECTION_ACTIONS } from './config' 10 | 11 | /** 12 | * SelectionToolbar - Complete floating toolbar for text selections 13 | * Integrates all toolbar components in the default layout 14 | */ 15 | export function SelectionToolbar() { 16 | const { editor } = useContext(EditorContext)! 17 | const { isDragging } = useControls(editor) 18 | const { shouldShow, getAnchorRect } = useFloatingToolbar(editor, { 19 | extraHideWhen: isDragging 20 | }) 21 | 22 | if (!editor) { 23 | return null 24 | } 25 | 26 | return ( 27 | 28 | {/* Left Section: Turn Into Dropdown */} 29 | 30 | 31 | 32 | 33 | {/* Center Section: Text Formatting Marks */} 34 | 35 | {CENTER_SECTION_ACTIONS.map((actionId) => ( 36 | 37 | ))} 38 | 39 | 40 | {/* Right Section: Link, Color, More */} 41 | 42 | 43 | 44 | 45 | 46 | 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /console/src/lib/timezones.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Valid IANA Timezone Identifiers 3 | * 4 | * This list is dynamically loaded from the backend via /config.js 5 | * The backend serves window.TIMEZONES with all valid timezone identifiers 6 | * from Go's embedded IANA timezone database. 7 | * 8 | * Source: Backend's internal/domain/timezones.go 9 | * Endpoint: /config.js (window.TIMEZONES) 10 | * 11 | * This ensures perfect synchronization between frontend and backend 12 | * without needing to maintain a separate static file. 13 | */ 14 | 15 | // Declare global window.TIMEZONES type 16 | declare global { 17 | interface Window { 18 | TIMEZONES?: string[] 19 | } 20 | } 21 | 22 | /** 23 | * Array of all valid IANA timezone identifiers accepted by the backend 24 | * Loaded from window.TIMEZONES which is served by /config.js 25 | */ 26 | export const VALID_TIMEZONES: readonly string[] = window.TIMEZONES || [] 27 | 28 | /** 29 | * Type representing any valid timezone identifier 30 | */ 31 | export type TimezoneIdentifier = string 32 | 33 | /** 34 | * Form options for Ant Design Select component 35 | */ 36 | export const TIMEZONE_OPTIONS = VALID_TIMEZONES.map(tz => ({ 37 | value: tz, 38 | label: tz, 39 | })) 40 | 41 | /** 42 | * Checks if a timezone string is valid according to the backend 43 | * 44 | * @param timezone - The timezone identifier to validate 45 | * @returns true if the timezone is valid 46 | */ 47 | export function isValidTimezone(timezone: string): timezone is TimezoneIdentifier { 48 | return VALID_TIMEZONES.includes(timezone) 49 | } 50 | 51 | /** 52 | * Total number of valid timezones 53 | */ 54 | export const TIMEZONE_COUNT = VALID_TIMEZONES.length 55 | 56 | /** 57 | * Checks if timezones have been loaded from the backend 58 | * 59 | * @returns true if timezones are available 60 | */ 61 | export function areTimezonesLoaded(): boolean { 62 | return TIMEZONE_COUNT > 0 63 | } 64 | -------------------------------------------------------------------------------- /internal/migrations/v5.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/Notifuse/notifuse/config" 8 | "github.com/Notifuse/notifuse/internal/domain" 9 | ) 10 | 11 | // V5Migration implements the migration from version 4.x to 5.0 12 | type V5Migration struct{} 13 | 14 | // GetMajorVersion returns the major version this migration handles 15 | func (m *V5Migration) GetMajorVersion() float64 { 16 | return 5.0 17 | } 18 | 19 | // HasSystemUpdate indicates if this migration has system-level changes 20 | func (m *V5Migration) HasSystemUpdate() bool { 21 | return false 22 | } 23 | 24 | // HasWorkspaceUpdate indicates if this migration has workspace-level changes 25 | func (m *V5Migration) HasWorkspaceUpdate() bool { 26 | return true 27 | } 28 | 29 | // ShouldRestartServer indicates if the server should restart after this migration 30 | func (m *V5Migration) ShouldRestartServer() bool { 31 | return false 32 | } 33 | 34 | // UpdateSystem executes system-level migration changes 35 | func (m *V5Migration) UpdateSystem(ctx context.Context, config *config.Config, db DBExecutor) error { 36 | // No system-level changes for v5 37 | return nil 38 | } 39 | 40 | // UpdateWorkspace executes workspace-level migration changes 41 | func (m *V5Migration) UpdateWorkspace(ctx context.Context, config *config.Config, workspace *domain.Workspace, db DBExecutor) error { 42 | // Add pause_reason column to broadcasts table 43 | // Using IF NOT EXISTS to make the migration idempotent 44 | _, err := db.ExecContext(ctx, ` 45 | ALTER TABLE broadcasts 46 | ADD COLUMN IF NOT EXISTS pause_reason TEXT 47 | `) 48 | if err != nil { 49 | return fmt.Errorf("failed to add pause_reason column to broadcasts table for workspace %s: %w", workspace.ID, err) 50 | } 51 | 52 | return nil 53 | } 54 | 55 | // init registers this migration with the default registry 56 | func init() { 57 | Register(&V5Migration{}) 58 | } 59 | -------------------------------------------------------------------------------- /console/src/components/blog_editor/hooks/useYoutube.ts: -------------------------------------------------------------------------------- 1 | import type { Editor } from '@tiptap/react' 2 | 3 | /** 4 | * Checks if YouTube embed can be inserted in the current editor state 5 | * 6 | * Validates: 7 | * - Editor is ready and editable 8 | * - YouTube node type exists in schema 9 | * 10 | * @param editor - The Tiptap editor instance 11 | * @returns true if YouTube embed can be inserted, false otherwise 12 | */ 13 | export function canInsertYoutube(editor: Editor | null): boolean { 14 | if (!editor || !editor.isEditable) return false 15 | 16 | // Check if youtube node exists in schema 17 | return !!editor.schema.nodes.youtube 18 | } 19 | 20 | /** 21 | * Checks if the current selection is within a YouTube node 22 | * 23 | * @param editor - The Tiptap editor instance 24 | * @returns true if current selection is in a YouTube node 25 | */ 26 | export function isYoutubeActive(editor: Editor | null): boolean { 27 | if (!editor) return false 28 | return editor.isActive('youtube') 29 | } 30 | 31 | /** 32 | * Inserts a YouTube embed at the current position 33 | * 34 | * @param editor - The Tiptap editor instance 35 | * @param url - Optional YouTube URL. If empty, shows input overlay 36 | * @returns true if insertion succeeded, false otherwise 37 | */ 38 | export function insertYoutube(editor: Editor | null, url?: string): boolean { 39 | if (!editor || !editor.isEditable) { 40 | return false 41 | } 42 | 43 | try { 44 | // Insert YouTube node directly using insertContent 45 | const result = editor 46 | .chain() 47 | .focus() 48 | .insertContent({ 49 | type: 'youtube', 50 | attrs: { 51 | src: url || '', 52 | width: 560, 53 | height: 315 54 | } 55 | }) 56 | .run() 57 | 58 | return result 59 | } catch (error) { 60 | console.error('YouTube insert error:', error) 61 | return false 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /console/src/components/filters/FilterInputs.tsx: -------------------------------------------------------------------------------- 1 | import { Input, DatePicker, Select, Switch } from 'antd' 2 | import type { FilterInputProps } from './types' 3 | 4 | export function StringFilterInput({ field, value, onChange, className }: FilterInputProps) { 5 | return ( 6 | onChange(e.target.value)} 10 | className={className} 11 | /> 12 | ) 13 | } 14 | 15 | export function NumberFilterInput({ field, value, onChange, className }: FilterInputProps) { 16 | return ( 17 | onChange(Number(e.target.value))} 22 | className={className} 23 | /> 24 | ) 25 | } 26 | 27 | export function DateFilterInput({ field, value, onChange, className }: FilterInputProps) { 28 | return ( 29 | onChange(date)} 33 | className={className} 34 | /> 35 | ) 36 | } 37 | 38 | export function BooleanFilterInput({ value, onChange, className }: FilterInputProps) { 39 | return 40 | } 41 | 42 | export function SelectFilterInput({ field, value, onChange, className }: FilterInputProps) { 43 | if (!field.options) return null 44 | 45 | return ( 46 | { 40 | return { 41 | value: op.type, 42 | label: op.label 43 | } 44 | })} 45 | /> 46 | 47 | 48 | 49 | {(funcs) => { 50 | const operator = this.operators.find((x) => x.type === funcs.getFieldValue('operator')) 51 | if (operator) return operator.renderFormItems(fieldType, fieldName, form) 52 | }} 53 | 54 | 55 | ) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /internal/domain/types.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "bytes" 5 | "database/sql/driver" 6 | "encoding/json" 7 | ) 8 | 9 | // MapOfAny is persisted as JSON in the database 10 | type MapOfAny map[string]any 11 | 12 | // Scan implements the sql.Scanner interface 13 | func (m *MapOfAny) Scan(val interface{}) error { 14 | 15 | var data []byte 16 | 17 | if b, ok := val.([]byte); ok { 18 | // VERY IMPORTANT: we need to clone the bytes here 19 | // The sql driver will reuse the same bytes RAM slots for future queries 20 | // Thank you St Antoine De Padoue for helping me find this bug 21 | data = bytes.Clone(b) 22 | } else if s, ok := val.(string); ok { 23 | data = []byte(s) 24 | } else if val == nil { 25 | return nil 26 | } 27 | 28 | return json.Unmarshal(data, m) 29 | } 30 | 31 | // Value implements the driver.Valuer interface 32 | func (m MapOfAny) Value() (driver.Value, error) { 33 | return json.Marshal(m) 34 | } 35 | 36 | // JSONArray is persisted as a JSON array in the database 37 | type JSONArray []interface{} 38 | 39 | // Scan implements the sql.Scanner interface 40 | func (a *JSONArray) Scan(val interface{}) error { 41 | var data []byte 42 | 43 | if b, ok := val.([]byte); ok { 44 | // Clone bytes to avoid reuse issues 45 | data = bytes.Clone(b) 46 | } else if s, ok := val.(string); ok { 47 | data = []byte(s) 48 | } else if val == nil { 49 | *a = nil 50 | return nil 51 | } 52 | 53 | if err := json.Unmarshal(data, a); err != nil { 54 | return err 55 | } 56 | 57 | // Convert float64 values that are actually integers back to int 58 | // This is necessary because json.Unmarshal converts all numbers to float64, 59 | // but PostgreSQL parameters need proper int types 60 | for i, v := range *a { 61 | if f, ok := v.(float64); ok { 62 | // Check if the float is actually an integer value 63 | if f == float64(int(f)) { 64 | (*a)[i] = int(f) 65 | } 66 | } 67 | } 68 | 69 | return nil 70 | } 71 | 72 | // Value implements the driver.Valuer interface 73 | func (a JSONArray) Value() (driver.Value, error) { 74 | if a == nil { 75 | return nil, nil 76 | } 77 | return json.Marshal(a) 78 | } 79 | --------------------------------------------------------------------------------