├── 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 |
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 | }
34 | onClick={() => openDrawer(workspaceId, lists, refreshOnClose)}
35 | className={className}
36 | style={style}
37 | size={size}
38 | disabled={disabled}
39 | >
40 | Import from CSV
41 |
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 |