├── .DS_Store ├── .github ├── FUNDING.yml └── workflows │ └── NPMBuild.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── README.md ├── components.json ├── composer.json ├── config └── config.php ├── eslint.config.js ├── jsconfig.json ├── package.json ├── phpunit.xml.dist ├── pint.json ├── postcss.config.js ├── public ├── .DS_Store ├── test-documents │ ├── onboarding-guide.pdf │ ├── product.jpg │ └── speaker-bios.docx └── vendor │ ├── .DS_Store │ └── mailweb │ ├── .vite │ └── manifest.json │ ├── mailweb.BnbzckCj.js │ └── mailweb.G9AMRQuM.css ├── resources ├── css │ └── app.css ├── js │ ├── Dashboard.vue │ ├── app.js │ ├── components │ │ ├── EmailAttachments.vue │ │ ├── EmailList.vue │ │ ├── EmailPreview.vue │ │ ├── Sidebar.vue │ │ ├── SidebarContent.vue │ │ ├── SlidingPanel.vue │ │ ├── icons │ │ │ └── Github.vue │ │ ├── partials │ │ │ ├── AnimatedSendButton.vue │ │ │ ├── AttachmentDialog.vue │ │ │ ├── DeleteEmailDialog.vue │ │ │ ├── SettingsDialog.vue │ │ │ └── ShareEmailDialog.vue │ │ └── ui │ │ │ ├── AttachmentIcon.vue │ │ │ ├── accordion │ │ │ ├── Accordion.vue │ │ │ ├── AccordionContent.vue │ │ │ ├── AccordionItem.vue │ │ │ ├── AccordionTrigger.vue │ │ │ └── index.ts │ │ │ ├── avatar │ │ │ ├── Avatar.vue │ │ │ ├── AvatarFallback.vue │ │ │ ├── AvatarImage.vue │ │ │ └── index.ts │ │ │ ├── badge │ │ │ ├── Badge.vue │ │ │ └── index.ts │ │ │ ├── button │ │ │ ├── Button.vue │ │ │ └── index.ts │ │ │ ├── dialog │ │ │ ├── Dialog.vue │ │ │ ├── DialogClose.vue │ │ │ ├── DialogContent.vue │ │ │ ├── DialogDescription.vue │ │ │ ├── DialogFooter.vue │ │ │ ├── DialogHeader.vue │ │ │ ├── DialogScrollContent.vue │ │ │ ├── DialogTitle.vue │ │ │ ├── DialogTrigger.vue │ │ │ └── index.ts │ │ │ ├── dropdown-menu │ │ │ ├── DropdownMenu.vue │ │ │ ├── DropdownMenuCheckboxItem.vue │ │ │ ├── DropdownMenuContent.vue │ │ │ ├── DropdownMenuGroup.vue │ │ │ ├── DropdownMenuItem.vue │ │ │ ├── DropdownMenuLabel.vue │ │ │ ├── DropdownMenuRadioGroup.vue │ │ │ ├── DropdownMenuRadioItem.vue │ │ │ ├── DropdownMenuSeparator.vue │ │ │ ├── DropdownMenuShortcut.vue │ │ │ ├── DropdownMenuSub.vue │ │ │ ├── DropdownMenuSubContent.vue │ │ │ ├── DropdownMenuSubTrigger.vue │ │ │ ├── DropdownMenuTrigger.vue │ │ │ └── index.ts │ │ │ ├── input │ │ │ ├── Input.vue │ │ │ └── index.ts │ │ │ ├── scroll-area │ │ │ ├── ScrollArea.vue │ │ │ ├── ScrollBar.vue │ │ │ └── index.ts │ │ │ ├── select │ │ │ ├── Select.vue │ │ │ ├── SelectContent.vue │ │ │ ├── SelectGroup.vue │ │ │ ├── SelectItem.vue │ │ │ ├── SelectItemText.vue │ │ │ ├── SelectLabel.vue │ │ │ ├── SelectScrollDownButton.vue │ │ │ ├── SelectScrollUpButton.vue │ │ │ ├── SelectSeparator.vue │ │ │ ├── SelectTrigger.vue │ │ │ ├── SelectValue.vue │ │ │ └── index.ts │ │ │ ├── separator │ │ │ ├── Separator.vue │ │ │ └── index.ts │ │ │ ├── sheet │ │ │ ├── Sheet.vue │ │ │ ├── SheetClose.vue │ │ │ ├── SheetContent.vue │ │ │ ├── SheetDescription.vue │ │ │ ├── SheetFooter.vue │ │ │ ├── SheetHeader.vue │ │ │ ├── SheetTitle.vue │ │ │ ├── SheetTrigger.vue │ │ │ └── index.ts │ │ │ ├── skeleton │ │ │ ├── Skeleton.vue │ │ │ └── index.ts │ │ │ ├── switch │ │ │ ├── Switch.vue │ │ │ └── index.ts │ │ │ ├── tabs │ │ │ ├── Tabs.vue │ │ │ ├── TabsContent.vue │ │ │ ├── TabsList.vue │ │ │ ├── TabsTrigger.vue │ │ │ └── index.ts │ │ │ └── tooltip │ │ │ ├── Tooltip.vue │ │ │ ├── TooltipContent.vue │ │ │ ├── TooltipProvider.vue │ │ │ ├── TooltipTrigger.vue │ │ │ └── index.ts │ ├── composables │ │ └── useMailwebConfig.ts │ ├── lib │ │ ├── utils.js │ │ └── utils.ts │ └── types │ │ └── email.ts └── views │ ├── dashboard.blade.php │ └── email-share.blade.php ├── src ├── Console │ └── Commands │ │ └── PruneMailwebMails.php ├── Facades │ └── MailWeb.php ├── Http │ ├── Controllers │ │ └── MailWebController.php │ ├── Listeners │ │ └── MailWebListener.php │ └── Models │ │ ├── MailwebEmail.php │ │ └── MailwebEmailAttachment.php ├── MailWebServiceProvider.php ├── Migrations │ ├── 0000_00_00_000000_create_mail_web_table.php │ ├── 0000_00_00_000001_alter_for_new_mail_web.php │ ├── 0000_00_00_000002_add_mail_web_attachment_table.php │ └── 0000_00_00_000003_add_size_to_mail_web_attachment_table.php ├── Notifications │ └── MailwebSampleNotification.php └── Providers │ └── MessageServiceProvider.php ├── tests ├── MailWebTestCase.php ├── Unit │ └── RouteTest.php └── factories │ └── UserFactory.php ├── tsconfig.json └── vite.config.js /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appoly/mail-web/7f337a2c7e9441503b09c1e1b79028a1b5d7d859/.DS_Store -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: appoly 2 | patreon: appoly 3 | -------------------------------------------------------------------------------- /.github/workflows/NPMBuild.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: NPMBuild 4 | 5 | # Controls when the action will run. Triggers the workflow on push or pull request 6 | # events but only for the master branch 7 | on: 8 | push: 9 | branches: [ master ] 10 | pull_request: 11 | branches: [ master ] 12 | 13 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 14 | 15 | jobs: 16 | npm-build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Install Dependencies 23 | run: npm install 24 | - name: run prod 25 | run: npm run prod 26 | - name: Commit files 27 | continue-on-error: true 28 | run: | 29 | git config --local user.email "appoly@github.com" 30 | git config --local user.name "NPM Builder" 31 | git commit -m "npm run prod" -a 32 | - name: Push changes 33 | continue-on-error: true 34 | uses: ad-m/github-push-action@master 35 | with: 36 | github_token: ${{ secrets.GITHUB_TOKEN }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | composer.lock 3 | docs 4 | vendor 5 | coverage 6 | node_modules 7 | package-lock.json 8 | !/public/vendor 9 | /.idea 10 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | resources/js/components/ui/* 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "singleAttributePerLine": false, 5 | "htmlWhitespaceSensitivity": "css", 6 | "printWidth": 150, 7 | "plugins": ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"], 8 | "tailwindFunctions": ["clsx", "cn"], 9 | "tabWidth": 4, 10 | "overrides": [ 11 | { 12 | "files": "**/*.yml", 13 | "options": { 14 | "tabWidth": 2 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[vue]": { 3 | "editor.defaultFormatter": "Vue.volar" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # MailWeb 4 | 5 | **A powerful Laravel email debugging and testing tool** 6 | 7 | [![Total Downloads](https://poser.pugx.org/appoly/mail-web/downloads?format=flat-square)](https://packagist.org/packages/appoly/mail-web) 8 | [![Latest Stable Version](https://poser.pugx.org/appoly/mail-web/v/stable?format=flat-square)](https://packagist.org/packages/appoly/mail-web) 9 | [![License](https://poser.pugx.org/appoly/mail-web/license?format=flat-square)](https://packagist.org/packages/appoly/mail-web) 10 | 11 |
12 | 13 | ## 🚀 Overview 14 | 15 | MailWeb is a robust Laravel package that revolutionizes email development and debugging. It seamlessly captures and displays your application's outgoing emails in real-time, making email testing and sharing effortless. 16 | 17 | ## ✨ Features 18 | 19 | - 📧 **Real-time Email Interception**: Catch and inspect outgoing emails instantly 20 | - 🎨 **Modern UI**: Beautiful, responsive interface for easy navigation 21 | - 🔍 **Powerful Search**: Quickly find emails with advanced search capabilities 22 | - 🔄 **Email Sharing**: Share email previews with your team effortlessly 23 | - 📎 **Attachment Support**: Handle email attachments with flexible storage options 24 | - 🛡️ **Secure Access Control**: Granular control over who can access the dashboard 25 | - 📱 **Mobile Responsive**: Optimized interface for both desktop and mobile devices 26 | 27 | ## 📋 Requirements 28 | 29 | - PHP 8.1 or higher 30 | - Laravel 9.21|10.0|11.0|12.0 31 | 32 | ## 🔧 Installation 33 | 34 | 1. Install via Composer: 35 | ```bash 36 | composer require appoly/mail-web 37 | ``` 38 | 39 | 2. Run migrations: 40 | ```bash 41 | php artisan migrate 42 | ``` 43 | 44 | 3. Publish config (if needed): 45 | ```bash 46 | php artisan vendor:publish --tag=mailweb-config --force 47 | ``` 48 | 49 | ## ⚙️ Configuration 50 | 51 | ### 1. Route Registration 52 | 53 | Add to your routes file: 54 | ```php 55 | Route::mailweb(); 56 | ``` 57 | 58 | ### 2. Access Control 59 | 60 | Add to your `AppServiceProvider` (Laravel 11+) or `AuthServiceProvider`: 61 | 62 | ```php 63 | use Illuminate\Support\Facades\Gate; 64 | 65 | public function boot() 66 | { 67 | Gate::define('view-mailweb', function ($user) { 68 | return in_array($user->email, [ 69 | 'admin@example.com', 70 | // Add authorized emails 71 | ]); 72 | }); 73 | } 74 | ``` 75 | 76 | ### 3. Local Development 77 | 78 | For local development, set in your `.env`: 79 | ```env 80 | MAIL_MAILER=log 81 | ``` 82 | 83 | ## 📝 Usage 84 | 85 | 1. Access the dashboard at: 86 | ``` 87 | {your-app-url}/mailweb 88 | ``` 89 | 90 | 2. Configure email storage limit in `.env`: 91 | ```env 92 | MAILWEB_LIMIT=30 # Default value 93 | ``` 94 | 95 | 3. Set up email pruning (recommended in `routes/console.php`): 96 | ```php 97 | use Illuminate\Support\Facades\Schedule; 98 | 99 | Schedule::command('mailweb:prune')->daily(); 100 | ``` 101 | 102 | ## 💾 Attachment Storage 103 | 104 | Configure attachment storage in your `.env`: 105 | ```env 106 | MAILWEB_ATTACHMENTS_DISK=s3 # Or any configured disk 107 | MAILWEB_ATTACHMENTS_PATH=/custom/path # Optional, defaults to /mailweb/attachments 108 | ``` 109 | 110 | 111 | 112 | ## 🤝 Contributing 113 | 114 | We welcome contributions! Please follow these steps: 115 | 116 | 1. Fork the repository 117 | 2. Create your feature branch 118 | 3. Commit your changes 119 | 4. Push to the branch 120 | 5. Open a Pull Request 121 | 122 | ### Local Development Setup 123 | 124 | 1. Clone the repository 125 | 2. Install dependencies: 126 | ```bash 127 | composer install 128 | ``` 129 | 130 | 3. Link to your test project - add to your test project's `composer.json`: 131 | ```json 132 | { 133 | "repositories": [ 134 | { 135 | "type": "path", 136 | "url": "../path/to/MailWeb", 137 | "options": { 138 | "symlink": true 139 | } 140 | } 141 | ], 142 | "require": { 143 | "appoly/mail-web": "@dev" 144 | } 145 | } 146 | ``` 147 | 4. Run `composer update appoly/mail-web` 148 | 149 | ## 📄 License 150 | 151 | This project is licensed under the [MIT License](https://choosealicense.com/licenses/mit/). 152 | 153 | --- 154 | 155 |
156 | Made by Appoly 157 |
158 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://shadcn-vue.com/schema.json", 3 | "style": "new-york", 4 | "typescript": true, 5 | "tailwind": { 6 | "config": "", 7 | "css": "resources/css/app.css", 8 | "baseColor": "neutral", 9 | "cssVariables": true, 10 | "prefix": "" 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "composables": "@/composables", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib" 18 | }, 19 | "iconLibrary": "lucide" 20 | } -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "appoly/mail-web", 3 | "description": "Catch your outgoing emails within your project making it easier to test and share", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Calum Chamberlain", 8 | "email": "calum@appoly.co.uk" 9 | } 10 | ], 11 | "autoload": { 12 | "psr-4": { 13 | "Appoly\\MailWeb\\": "src" 14 | } 15 | }, 16 | "autoload-dev": { 17 | "psr-4": { 18 | "Appoly\\MailWeb\\Tests\\": "tests" 19 | } 20 | }, 21 | "require": { 22 | "php": "^8.1", 23 | "illuminate/console": "*", 24 | "illuminate/support": "^9.21|^10.0|^11.0|^12.0" 25 | }, 26 | "require-dev": { 27 | "laravel/pint": "^1.13", 28 | "orchestra/testbench": "^8.0|^9.0|^10.0", 29 | "phpunit/phpunit": "^9.0|^11.5.3" 30 | }, 31 | "extra": { 32 | "laravel": { 33 | "providers": [ 34 | "Appoly\\MailWeb\\MailWebServiceProvider" 35 | ], 36 | "aliases": { 37 | "MailWeb": "Appoly\\MailWeb\\MailWebFacade" 38 | } 39 | } 40 | }, 41 | "scripts": { 42 | "test": "vendor/bin/phpunit", 43 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 44 | }, 45 | "config": { 46 | "sort-packages": true 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /config/config.php: -------------------------------------------------------------------------------- 1 | env('MAILWEB_ENABLED', true), 14 | 'MAILWEB_LIMIT' => env('MAILWEB_LIMIT', 30), 15 | 'MAILWEB_SEND_SAMPLE_BUTTON' => env('MAILWEB_SEND_SAMPLE_BUTTON', true), 16 | 'MAILWEB_DELETE_ALL_ENABLED' => env('MAILWEB_DELETE_ALL_ENABLED', false), 17 | 'MAILWEB_RETURN' => [ 18 | 'APP_NAME' => env('MAILWEB_RETURN_APP_NAME'), 19 | 'APP_URL' => env('MAILWEB_RETURN_APP_URL'), 20 | ], 21 | 'MAILWEB_ATTACHMENTS' => [ 22 | 'DISK' => env('MAILWEB_ATTACHMENTS_DISK'), 23 | 'PATH' => env('MAILWEB_ATTACHMENTS_PATH', 'mailweb/attachments'), 24 | ], 25 | ]; 26 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import prettier from 'eslint-config-prettier'; 2 | import vue from 'eslint-plugin-vue'; 3 | 4 | import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'; 5 | 6 | export default defineConfigWithVueTs( 7 | vue.configs['flat/essential'], 8 | vueTsConfigs.recommended, 9 | { 10 | ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr', 'tailwind.config.js', 'resources/js/components/ui/*'], 11 | }, 12 | { 13 | rules: { 14 | 'vue/multi-word-component-names': 'off', 15 | '@typescript-eslint/no-explicit-any': 'off', 16 | }, 17 | }, 18 | prettier, 19 | ); 20 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["resources/js/*"] 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vite build", 7 | "prod": "vite build", 8 | "format": "prettier --write resources/", 9 | "format:check": "prettier --check resources/", 10 | "lint": "eslint . --fix" 11 | }, 12 | "dependencies": { 13 | "@tailwindcss/vite": "^4.0.9", 14 | "@vueuse/core": "^12.8.2", 15 | "axios": "^1.6.7", 16 | "class-variance-authority": "^0.7.1", 17 | "clsx": "^2.1.1", 18 | "date-fns": "^4.1.0", 19 | "dompurify": "^3.2.4", 20 | "lean-qr": "^2.4.1", 21 | "lucide-vue-next": "^0.476.0", 22 | "reka-ui": "^2.0.2", 23 | "tailwind-merge": "^3.2.0", 24 | "tailwindcss-animate": "^1.0.7", 25 | "tw-animate-css": "^1.2.5", 26 | "vue3-hot-toast": "^0.0.5" 27 | }, 28 | "devDependencies": { 29 | "@eslint/js": "^9.19.0", 30 | "@tailwindcss/postcss": "^4.1.3", 31 | "@vitejs/plugin-vue": "^5.2.1", 32 | "@vue/eslint-config-typescript": "^14.3.0", 33 | "eslint": "^9.17.0", 34 | "eslint-config-prettier": "^10.0.1", 35 | "eslint-plugin-vue": "^9.32.0", 36 | "laravel-vite-plugin": "^1.0", 37 | "prettier": "^3.4.2", 38 | "prettier-plugin-organize-imports": "^4.1.0", 39 | "prettier-plugin-tailwindcss": "^0.6.11", 40 | "tailwindcss": "^4.1.3", 41 | "typescript-eslint": "^8.23.0", 42 | "vite": "^6.0.3", 43 | "vue": "^3.5.13", 44 | "vue-tsc": "^2.2.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | tests 15 | 16 | 17 | 18 | 19 | src/ 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "rules": { 4 | "class_attributes_separation": { 5 | "elements": { 6 | "const": "none" 7 | } 8 | }, 9 | "concat_space": { 10 | "spacing": "one" 11 | }, 12 | "ordered_imports":{ 13 | "sort_algorithm": "length" 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | }, 5 | }; -------------------------------------------------------------------------------- /public/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appoly/mail-web/7f337a2c7e9441503b09c1e1b79028a1b5d7d859/public/.DS_Store -------------------------------------------------------------------------------- /public/test-documents/onboarding-guide.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appoly/mail-web/7f337a2c7e9441503b09c1e1b79028a1b5d7d859/public/test-documents/onboarding-guide.pdf -------------------------------------------------------------------------------- /public/test-documents/product.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appoly/mail-web/7f337a2c7e9441503b09c1e1b79028a1b5d7d859/public/test-documents/product.jpg -------------------------------------------------------------------------------- /public/test-documents/speaker-bios.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appoly/mail-web/7f337a2c7e9441503b09c1e1b79028a1b5d7d859/public/test-documents/speaker-bios.docx -------------------------------------------------------------------------------- /public/vendor/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/appoly/mail-web/7f337a2c7e9441503b09c1e1b79028a1b5d7d859/public/vendor/.DS_Store -------------------------------------------------------------------------------- /public/vendor/mailweb/.vite/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "resources/js/app.js": { 3 | "file": "mailweb.BnbzckCj.js", 4 | "name": "app", 5 | "src": "resources/js/app.js", 6 | "isEntry": true 7 | }, 8 | "style.css": { 9 | "file": "mailweb.G9AMRQuM.css", 10 | "src": "style.css" 11 | } 12 | } -------------------------------------------------------------------------------- /resources/css/app.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @custom-variant dark (&:is(.dark *)); 4 | 5 | @plugin 'tailwindcss-animate'; 6 | 7 | @theme inline { 8 | --color-background: var(--background); 9 | 10 | --color-foreground: var(--foreground); 11 | 12 | --color-card: var(--card); 13 | 14 | --color-card-foreground: var(--card-foreground); 15 | 16 | --color-popover: var(--popover); 17 | 18 | --color-popover-foreground: var(--popover-foreground); 19 | 20 | --color-primary: var(--primary); 21 | 22 | --color-primary-foreground: var(--primary-foreground); 23 | 24 | --color-secondary: var(--secondary); 25 | 26 | --color-secondary-foreground: var(--secondary-foreground); 27 | 28 | --color-muted: var(--muted); 29 | 30 | --color-muted-foreground: var(--muted-foreground); 31 | 32 | --color-accent: var(--accent); 33 | 34 | --color-accent-foreground: var(--accent-foreground); 35 | 36 | --color-destructive: var(--destructive); 37 | 38 | --color-destructive-foreground: var(--destructive-foreground); 39 | 40 | --color-border: var(--border); 41 | 42 | --color-input: var(--input); 43 | 44 | --color-ring: var(--ring); 45 | 46 | --color-chart-1: var(--chart-1); 47 | 48 | --color-chart-2: var(--chart-2); 49 | 50 | --color-chart-3: var(--chart-3); 51 | 52 | --color-chart-4: var(--chart-4); 53 | 54 | --color-chart-5: var(--chart-5); 55 | 56 | --radius-sm: calc(var(--radius) - 4px); 57 | 58 | --radius-md: calc(var(--radius) - 2px); 59 | 60 | --radius-lg: var(--radius); 61 | 62 | --radius-xl: calc(var(--radius) + 4px); 63 | 64 | --color-sidebar: var(--sidebar); 65 | 66 | --color-sidebar-foreground: var(--sidebar-foreground); 67 | 68 | --color-sidebar-primary: var(--sidebar-primary); 69 | 70 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 71 | 72 | --color-sidebar-accent: var(--sidebar-accent); 73 | 74 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 75 | 76 | --color-sidebar-border: var(--sidebar-border); 77 | 78 | --color-sidebar-ring: var(--sidebar-ring); 79 | } 80 | 81 | :root { 82 | --background: oklch(1 0 0); 83 | 84 | --foreground: oklch(0.145 0 0); 85 | 86 | --card: oklch(1 0 0); 87 | 88 | --card-foreground: oklch(0.145 0 0); 89 | 90 | --popover: oklch(1 0 0); 91 | 92 | --popover-foreground: oklch(0.145 0 0); 93 | 94 | --primary: oklch(0.205 0 0); 95 | 96 | --primary-foreground: oklch(0.985 0 0); 97 | 98 | --secondary: oklch(0.97 0 0); 99 | 100 | --secondary-foreground: oklch(0.205 0 0); 101 | 102 | --muted: oklch(0.97 0 0); 103 | 104 | --muted-foreground: oklch(0.556 0 0); 105 | 106 | --accent: oklch(0.97 0 0); 107 | 108 | --accent-foreground: oklch(0.205 0 0); 109 | 110 | --destructive: oklch(0.577 0.245 27.325); 111 | 112 | --destructive-foreground: oklch(0.577 0.245 27.325); 113 | 114 | --border: oklch(0.922 0 0); 115 | 116 | --input: oklch(0.922 0 0); 117 | 118 | --ring: oklch(0.708 0 0); 119 | 120 | --chart-1: oklch(0.646 0.222 41.116); 121 | 122 | --chart-2: oklch(0.6 0.118 184.704); 123 | 124 | --chart-3: oklch(0.398 0.07 227.392); 125 | 126 | --chart-4: oklch(0.828 0.189 84.429); 127 | 128 | --chart-5: oklch(0.769 0.188 70.08); 129 | 130 | --radius: 0.625rem; 131 | 132 | --sidebar: oklch(0.985 0 0); 133 | 134 | --sidebar-foreground: oklch(0.145 0 0); 135 | 136 | --sidebar-primary: oklch(0.205 0 0); 137 | 138 | --sidebar-primary-foreground: oklch(0.985 0 0); 139 | 140 | --sidebar-accent: oklch(0.97 0 0); 141 | 142 | --sidebar-accent-foreground: oklch(0.205 0 0); 143 | 144 | --sidebar-border: oklch(0.922 0 0); 145 | 146 | --sidebar-ring: oklch(0.708 0 0); 147 | } 148 | 149 | .dark { 150 | --background: oklch(0.145 0 0); 151 | 152 | --foreground: oklch(0.985 0 0); 153 | 154 | --card: oklch(0.145 0 0); 155 | 156 | --card-foreground: oklch(0.985 0 0); 157 | 158 | --popover: oklch(0.145 0 0); 159 | 160 | --popover-foreground: oklch(0.985 0 0); 161 | 162 | --primary: oklch(0.985 0 0); 163 | 164 | --primary-foreground: oklch(0.205 0 0); 165 | 166 | --secondary: oklch(0.269 0 0); 167 | 168 | --secondary-foreground: oklch(0.985 0 0); 169 | 170 | --muted: oklch(0.269 0 0); 171 | 172 | --muted-foreground: oklch(0.708 0 0); 173 | 174 | --accent: oklch(0.269 0 0); 175 | 176 | --accent-foreground: oklch(0.985 0 0); 177 | 178 | --destructive: oklch(0.396 0.141 25.723); 179 | 180 | --destructive-foreground: oklch(0.637 0.237 25.331); 181 | 182 | --border: oklch(0.269 0 0); 183 | 184 | --input: oklch(0.269 0 0); 185 | 186 | --ring: oklch(0.439 0 0); 187 | 188 | --chart-1: oklch(0.488 0.243 264.376); 189 | 190 | --chart-2: oklch(0.696 0.17 162.48); 191 | 192 | --chart-3: oklch(0.769 0.188 70.08); 193 | 194 | --chart-4: oklch(0.627 0.265 303.9); 195 | 196 | --chart-5: oklch(0.645 0.246 16.439); 197 | 198 | --sidebar: oklch(0.205 0 0); 199 | 200 | --sidebar-foreground: oklch(0.985 0 0); 201 | 202 | --sidebar-primary: oklch(0.488 0.243 264.376); 203 | 204 | --sidebar-primary-foreground: oklch(0.985 0 0); 205 | 206 | --sidebar-accent: oklch(0.269 0 0); 207 | 208 | --sidebar-accent-foreground: oklch(0.985 0 0); 209 | 210 | --sidebar-border: oklch(0.269 0 0); 211 | 212 | --sidebar-ring: oklch(0.439 0 0); 213 | } 214 | 215 | @layer base { 216 | * { 217 | @apply border-border outline-ring/50; 218 | } 219 | body { 220 | @apply bg-background text-foreground; 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /resources/js/Dashboard.vue: -------------------------------------------------------------------------------- 1 | 186 | 187 | 276 | -------------------------------------------------------------------------------- /resources/js/app.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import '../css/app.css'; 3 | import App from './Dashboard.vue'; 4 | 5 | const app = createApp(App); 6 | app.mount('#mailweb-dashboard'); 7 | -------------------------------------------------------------------------------- /resources/js/components/EmailAttachments.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 55 | -------------------------------------------------------------------------------- /resources/js/components/EmailList.vue: -------------------------------------------------------------------------------- 1 | 120 | 121 | 223 | -------------------------------------------------------------------------------- /resources/js/components/Sidebar.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 45 | -------------------------------------------------------------------------------- /resources/js/components/SidebarContent.vue: -------------------------------------------------------------------------------- 1 | 125 | 126 | 219 | -------------------------------------------------------------------------------- /resources/js/components/SlidingPanel.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 48 | -------------------------------------------------------------------------------- /resources/js/components/icons/Github.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | -------------------------------------------------------------------------------- /resources/js/components/partials/AnimatedSendButton.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 31 | 32 | 66 | -------------------------------------------------------------------------------- /resources/js/components/partials/AttachmentDialog.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 125 | -------------------------------------------------------------------------------- /resources/js/components/partials/DeleteEmailDialog.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 36 | -------------------------------------------------------------------------------- /resources/js/components/partials/SettingsDialog.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | 163 | -------------------------------------------------------------------------------- /resources/js/components/partials/ShareEmailDialog.vue: -------------------------------------------------------------------------------- 1 | 154 | 155 | 244 | -------------------------------------------------------------------------------- /resources/js/components/ui/AttachmentIcon.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | -------------------------------------------------------------------------------- /resources/js/components/ui/accordion/Accordion.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /resources/js/components/ui/accordion/AccordionContent.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 25 | -------------------------------------------------------------------------------- /resources/js/components/ui/accordion/AccordionItem.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 25 | -------------------------------------------------------------------------------- /resources/js/components/ui/accordion/AccordionTrigger.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 40 | -------------------------------------------------------------------------------- /resources/js/components/ui/accordion/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Accordion } from './Accordion.vue' 2 | export { default as AccordionContent } from './AccordionContent.vue' 3 | export { default as AccordionItem } from './AccordionItem.vue' 4 | export { default as AccordionTrigger } from './AccordionTrigger.vue' 5 | -------------------------------------------------------------------------------- /resources/js/components/ui/avatar/Avatar.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 22 | -------------------------------------------------------------------------------- /resources/js/components/ui/avatar/AvatarFallback.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /resources/js/components/ui/avatar/AvatarImage.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /resources/js/components/ui/avatar/index.ts: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from 'class-variance-authority' 2 | 3 | export { default as Avatar } from './Avatar.vue' 4 | export { default as AvatarFallback } from './AvatarFallback.vue' 5 | export { default as AvatarImage } from './AvatarImage.vue' 6 | 7 | export const avatarVariant = cva( 8 | 'inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden', 9 | { 10 | variants: { 11 | size: { 12 | sm: 'h-10 w-10 text-xs', 13 | base: 'h-16 w-16 text-2xl', 14 | lg: 'h-32 w-32 text-5xl', 15 | }, 16 | shape: { 17 | circle: 'rounded-full', 18 | square: 'rounded-md', 19 | }, 20 | }, 21 | }, 22 | ) 23 | 24 | export type AvatarVariants = VariantProps 25 | -------------------------------------------------------------------------------- /resources/js/components/ui/badge/Badge.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | -------------------------------------------------------------------------------- /resources/js/components/ui/badge/index.ts: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from 'class-variance-authority' 2 | 3 | export { default as Badge } from './Badge.vue' 4 | 5 | export const badgeVariants = cva( 6 | 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2', 7 | { 8 | variants: { 9 | variant: { 10 | default: 11 | 'border-transparent bg-primary text-primary-foreground shadow-sm hover:bg-primary/80', 12 | secondary: 13 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', 14 | destructive: 15 | 'border-transparent bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/80', 16 | outline: 'text-foreground', 17 | }, 18 | }, 19 | defaultVariants: { 20 | variant: 'default', 21 | }, 22 | }, 23 | ) 24 | 25 | export type BadgeVariants = VariantProps 26 | -------------------------------------------------------------------------------- /resources/js/components/ui/button/Button.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 27 | -------------------------------------------------------------------------------- /resources/js/components/ui/button/index.ts: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from 'class-variance-authority' 2 | 3 | export { default as Button } from './Button.vue' 4 | 5 | export const buttonVariants = cva( 6 | 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 7 | { 8 | variants: { 9 | variant: { 10 | default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90', 11 | destructive: 12 | 'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90', 13 | outline: 14 | 'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground', 15 | secondary: 16 | 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', 17 | ghost: 'hover:bg-accent hover:text-accent-foreground', 18 | link: 'text-primary underline-offset-4 hover:underline', 19 | }, 20 | size: { 21 | default: 'h-9 px-4 py-2', 22 | xs: 'h-7 rounded px-2', 23 | sm: 'h-8 rounded-md px-3 text-xs', 24 | lg: 'h-10 rounded-md px-8', 25 | icon: 'h-9 w-9', 26 | }, 27 | }, 28 | defaultVariants: { 29 | variant: 'default', 30 | size: 'default', 31 | }, 32 | }, 33 | ) 34 | 35 | export type ButtonVariants = VariantProps 36 | -------------------------------------------------------------------------------- /resources/js/components/ui/dialog/Dialog.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /resources/js/components/ui/dialog/DialogClose.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /resources/js/components/ui/dialog/DialogContent.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 51 | -------------------------------------------------------------------------------- /resources/js/components/ui/dialog/DialogDescription.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 25 | -------------------------------------------------------------------------------- /resources/js/components/ui/dialog/DialogFooter.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 20 | -------------------------------------------------------------------------------- /resources/js/components/ui/dialog/DialogHeader.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 17 | -------------------------------------------------------------------------------- /resources/js/components/ui/dialog/DialogScrollContent.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 60 | -------------------------------------------------------------------------------- /resources/js/components/ui/dialog/DialogTitle.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 30 | -------------------------------------------------------------------------------- /resources/js/components/ui/dialog/DialogTrigger.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /resources/js/components/ui/dialog/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Dialog } from './Dialog.vue' 2 | export { default as DialogClose } from './DialogClose.vue' 3 | export { default as DialogContent } from './DialogContent.vue' 4 | export { default as DialogDescription } from './DialogDescription.vue' 5 | export { default as DialogFooter } from './DialogFooter.vue' 6 | export { default as DialogHeader } from './DialogHeader.vue' 7 | export { default as DialogScrollContent } from './DialogScrollContent.vue' 8 | export { default as DialogTitle } from './DialogTitle.vue' 9 | export { default as DialogTrigger } from './DialogTrigger.vue' 10 | -------------------------------------------------------------------------------- /resources/js/components/ui/dropdown-menu/DropdownMenu.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /resources/js/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 41 | -------------------------------------------------------------------------------- /resources/js/components/ui/dropdown-menu/DropdownMenuContent.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 39 | -------------------------------------------------------------------------------- /resources/js/components/ui/dropdown-menu/DropdownMenuGroup.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /resources/js/components/ui/dropdown-menu/DropdownMenuItem.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 29 | -------------------------------------------------------------------------------- /resources/js/components/ui/dropdown-menu/DropdownMenuLabel.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 25 | -------------------------------------------------------------------------------- /resources/js/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /resources/js/components/ui/dropdown-menu/DropdownMenuRadioItem.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 42 | -------------------------------------------------------------------------------- /resources/js/components/ui/dropdown-menu/DropdownMenuSeparator.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /resources/js/components/ui/dropdown-menu/DropdownMenuShortcut.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /resources/js/components/ui/dropdown-menu/DropdownMenuSub.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /resources/js/components/ui/dropdown-menu/DropdownMenuSubContent.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 31 | -------------------------------------------------------------------------------- /resources/js/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 34 | -------------------------------------------------------------------------------- /resources/js/components/ui/dropdown-menu/DropdownMenuTrigger.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | -------------------------------------------------------------------------------- /resources/js/components/ui/dropdown-menu/index.ts: -------------------------------------------------------------------------------- 1 | export { default as DropdownMenu } from './DropdownMenu.vue' 2 | 3 | export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue' 4 | export { default as DropdownMenuContent } from './DropdownMenuContent.vue' 5 | export { default as DropdownMenuGroup } from './DropdownMenuGroup.vue' 6 | export { default as DropdownMenuItem } from './DropdownMenuItem.vue' 7 | export { default as DropdownMenuLabel } from './DropdownMenuLabel.vue' 8 | export { default as DropdownMenuRadioGroup } from './DropdownMenuRadioGroup.vue' 9 | export { default as DropdownMenuRadioItem } from './DropdownMenuRadioItem.vue' 10 | export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue' 11 | export { default as DropdownMenuShortcut } from './DropdownMenuShortcut.vue' 12 | export { default as DropdownMenuSub } from './DropdownMenuSub.vue' 13 | export { default as DropdownMenuSubContent } from './DropdownMenuSubContent.vue' 14 | export { default as DropdownMenuSubTrigger } from './DropdownMenuSubTrigger.vue' 15 | export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue' 16 | export { DropdownMenuPortal } from 'reka-ui' 17 | -------------------------------------------------------------------------------- /resources/js/components/ui/input/Input.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 25 | -------------------------------------------------------------------------------- /resources/js/components/ui/input/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Input } from './Input.vue' 2 | -------------------------------------------------------------------------------- /resources/js/components/ui/scroll-area/ScrollArea.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 30 | -------------------------------------------------------------------------------- /resources/js/components/ui/scroll-area/ScrollBar.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 31 | -------------------------------------------------------------------------------- /resources/js/components/ui/scroll-area/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ScrollArea } from './ScrollArea.vue' 2 | export { default as ScrollBar } from './ScrollBar.vue' 3 | -------------------------------------------------------------------------------- /resources/js/components/ui/select/Select.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /resources/js/components/ui/select/SelectContent.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 54 | -------------------------------------------------------------------------------- /resources/js/components/ui/select/SelectGroup.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /resources/js/components/ui/select/SelectItem.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 45 | -------------------------------------------------------------------------------- /resources/js/components/ui/select/SelectItemText.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /resources/js/components/ui/select/SelectLabel.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | -------------------------------------------------------------------------------- /resources/js/components/ui/select/SelectScrollDownButton.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 25 | -------------------------------------------------------------------------------- /resources/js/components/ui/select/SelectScrollUpButton.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 25 | -------------------------------------------------------------------------------- /resources/js/components/ui/select/SelectSeparator.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 18 | -------------------------------------------------------------------------------- /resources/js/components/ui/select/SelectTrigger.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 32 | -------------------------------------------------------------------------------- /resources/js/components/ui/select/SelectValue.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /resources/js/components/ui/select/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Select } from './Select.vue' 2 | export { default as SelectContent } from './SelectContent.vue' 3 | export { default as SelectGroup } from './SelectGroup.vue' 4 | export { default as SelectItem } from './SelectItem.vue' 5 | export { default as SelectItemText } from './SelectItemText.vue' 6 | export { default as SelectLabel } from './SelectLabel.vue' 7 | export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue' 8 | export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue' 9 | export { default as SelectSeparator } from './SelectSeparator.vue' 10 | export { default as SelectTrigger } from './SelectTrigger.vue' 11 | export { default as SelectValue } from './SelectValue.vue' 12 | -------------------------------------------------------------------------------- /resources/js/components/ui/separator/Separator.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 39 | -------------------------------------------------------------------------------- /resources/js/components/ui/separator/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Separator } from './Separator.vue' 2 | -------------------------------------------------------------------------------- /resources/js/components/ui/sheet/Sheet.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /resources/js/components/ui/sheet/SheetClose.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /resources/js/components/ui/sheet/SheetContent.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 57 | -------------------------------------------------------------------------------- /resources/js/components/ui/sheet/SheetDescription.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 23 | -------------------------------------------------------------------------------- /resources/js/components/ui/sheet/SheetFooter.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 20 | -------------------------------------------------------------------------------- /resources/js/components/ui/sheet/SheetHeader.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 17 | -------------------------------------------------------------------------------- /resources/js/components/ui/sheet/SheetTitle.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 23 | -------------------------------------------------------------------------------- /resources/js/components/ui/sheet/SheetTrigger.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /resources/js/components/ui/sheet/index.ts: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from 'class-variance-authority' 2 | 3 | export { default as Sheet } from './Sheet.vue' 4 | export { default as SheetClose } from './SheetClose.vue' 5 | export { default as SheetContent } from './SheetContent.vue' 6 | export { default as SheetDescription } from './SheetDescription.vue' 7 | export { default as SheetFooter } from './SheetFooter.vue' 8 | export { default as SheetHeader } from './SheetHeader.vue' 9 | export { default as SheetTitle } from './SheetTitle.vue' 10 | export { default as SheetTrigger } from './SheetTrigger.vue' 11 | 12 | export const sheetVariants = cva( 13 | 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500', 14 | { 15 | variants: { 16 | side: { 17 | top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', 18 | bottom: 19 | 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', 20 | left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', 21 | right: 22 | 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm', 23 | }, 24 | }, 25 | defaultVariants: { 26 | side: 'right', 27 | }, 28 | }, 29 | ) 30 | 31 | export type SheetVariants = VariantProps 32 | -------------------------------------------------------------------------------- /resources/js/components/ui/skeleton/Skeleton.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 15 | -------------------------------------------------------------------------------- /resources/js/components/ui/skeleton/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Skeleton } from './Skeleton.vue' 2 | -------------------------------------------------------------------------------- /resources/js/components/ui/switch/Switch.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 40 | -------------------------------------------------------------------------------- /resources/js/components/ui/switch/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Switch } from './Switch.vue' 2 | -------------------------------------------------------------------------------- /resources/js/components/ui/tabs/Tabs.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /resources/js/components/ui/tabs/TabsContent.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 23 | -------------------------------------------------------------------------------- /resources/js/components/ui/tabs/TabsList.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 26 | -------------------------------------------------------------------------------- /resources/js/components/ui/tabs/TabsTrigger.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 30 | -------------------------------------------------------------------------------- /resources/js/components/ui/tabs/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Tabs } from './Tabs.vue' 2 | export { default as TabsContent } from './TabsContent.vue' 3 | export { default as TabsList } from './TabsList.vue' 4 | export { default as TabsTrigger } from './TabsTrigger.vue' 5 | -------------------------------------------------------------------------------- /resources/js/components/ui/tooltip/Tooltip.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /resources/js/components/ui/tooltip/TooltipContent.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 32 | -------------------------------------------------------------------------------- /resources/js/components/ui/tooltip/TooltipProvider.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /resources/js/components/ui/tooltip/TooltipTrigger.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /resources/js/components/ui/tooltip/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Tooltip } from './Tooltip.vue' 2 | export { default as TooltipContent } from './TooltipContent.vue' 3 | export { default as TooltipProvider } from './TooltipProvider.vue' 4 | export { default as TooltipTrigger } from './TooltipTrigger.vue' 5 | -------------------------------------------------------------------------------- /resources/js/composables/useMailwebConfig.ts: -------------------------------------------------------------------------------- 1 | // Define the MailwebConfig interface 2 | export interface MailwebConfig { 3 | deleteAllEnabled: boolean; 4 | sendSampleButton: boolean; 5 | return: { 6 | appName: string; 7 | appUrl: string; 8 | }; 9 | } 10 | 11 | // Declare the global window interface extension 12 | declare global { 13 | interface Window { 14 | mailwebConfig?: MailwebConfig; 15 | } 16 | } 17 | 18 | /** 19 | * Composable to access the mailweb configuration 20 | * This provides a centralized way to access the config that is injected 21 | * by the MailWebServiceProvider 22 | */ 23 | export function useMailwebConfig() { 24 | // Default configuration values 25 | const defaultConfig: MailwebConfig = { 26 | deleteAllEnabled: false, 27 | sendSampleButton: true, 28 | return: { 29 | appName: 'App', 30 | appUrl: '/', 31 | }, 32 | }; 33 | 34 | // Get the config from the window object or use defaults 35 | const getConfig = (): MailwebConfig => { 36 | return window.mailwebConfig || defaultConfig; 37 | }; 38 | 39 | // Individual getters for specific config values 40 | const isDeleteAllEnabled = (): boolean => { 41 | return getConfig().deleteAllEnabled; 42 | }; 43 | 44 | const isSendSampleButtonEnabled = (): boolean => { 45 | return getConfig().sendSampleButton; 46 | }; 47 | 48 | const getReturnConfig = (): { appName: string; appUrl: string } => { 49 | return getConfig().return; 50 | }; 51 | 52 | return { 53 | getConfig, 54 | isDeleteAllEnabled, 55 | isSendSampleButtonEnabled, 56 | getReturnConfig, 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /resources/js/lib/utils.js: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | 8 | export function valueUpdater(updaterOrValue, ref) { 9 | ref.value = typeof updaterOrValue === 'function' ? updaterOrValue(ref.value) : updaterOrValue; 10 | } 11 | -------------------------------------------------------------------------------- /resources/js/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /resources/js/types/email.ts: -------------------------------------------------------------------------------- 1 | // Email address interface 2 | export interface EmailAddress { 3 | name: string; 4 | address: string; 5 | } 6 | 7 | export interface EmailAttachment { 8 | id: string; 9 | name: string; 10 | path: string; 11 | human_readable_size: string; 12 | download_url: string; 13 | } 14 | 15 | // Email interface for EmailPreview component 16 | export interface EmailPreview { 17 | id: string; 18 | subject: string; 19 | from: EmailAddress[]; 20 | to: EmailAddress[]; 21 | cc?: EmailAddress[]; 22 | bcc?: EmailAddress[]; 23 | body_html: string; 24 | body_text: string; 25 | read: number; 26 | share_enabled: number; 27 | share_url?: string; 28 | created_at: string; 29 | updated_at: string; 30 | attachments?: EmailAttachment[]; 31 | } 32 | 33 | // Generic Email interface that can be used across components 34 | export interface Email { 35 | id: string; 36 | subject: string; 37 | from: EmailAddress[]; 38 | to: EmailAddress[]; 39 | cc?: EmailAddress[]; 40 | bcc?: EmailAddress[]; 41 | body_html: string; 42 | body_text: string; 43 | read: number; 44 | share_enabled: number; 45 | created_at: string; 46 | updated_at: string; 47 | headers?: string; 48 | share_url?: string; 49 | attachments_count?: number; 50 | } 51 | -------------------------------------------------------------------------------- /resources/views/dashboard.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | MailWeb Dashboard 5 | {!! Appoly\MailWeb\MailWebServiceProvider::css() !!} 6 | 7 | 8 |
9 | {!! Appoly\MailWeb\MailWebServiceProvider::js() !!} 10 | 11 | -------------------------------------------------------------------------------- /resources/views/email-share.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ $email->subject }} - Shared Email 7 | 8 | 77 | 78 | 79 |
80 | 117 |
118 | 119 | 120 | -------------------------------------------------------------------------------- /src/Console/Commands/PruneMailwebMails.php: -------------------------------------------------------------------------------- 1 | argument('remaining'); 33 | if ($remaining === null) { 34 | $remaining = config('MailWeb.MAILWEB_LIMIT', 30); 35 | } 36 | 37 | $remaining = (int) $remaining; 38 | 39 | $emailIdsToDeleteKeyedById = MailwebEmail::latest() 40 | ->withCount(['attachments as hasFileAttachments' => fn($q) => $q->whereNotNull('path')]) 41 | ->limit(5_000) // We need a limit to use offset, and to avoid memory issues. 5k is... probably enough, as this can just run more often if not 42 | ->offset($remaining) // We keep only the amount specified, so offset by that number 43 | ->pluck('hasFileAttachments', 'id') // Key of email ID, val of count of attachments 44 | ->toArray(); 45 | 46 | $emailIdsWithAttachments = array_keys(array_filter($emailIdsToDeleteKeyedById, fn($count) => $count > 0)); 47 | 48 | Log::info('Pruning ' . count($emailIdsToDeleteKeyedById) . ' emails, of which ' . count($emailIdsWithAttachments) . ' have attachments'); 49 | 50 | // Attachment cleanup needs to be done slowly 51 | if (count($emailIdsToDeleteKeyedById) > 0) { 52 | dispatch(function () use ($emailIdsWithAttachments) { 53 | // Chunk into batches of 100 for deletion 54 | $storageDisk = config('MailWeb.MAILWEB_ATTACHMENTS.DISK'); 55 | if ($storageDisk) { 56 | $basePath = config('MailWeb.MAILWEB_ATTACHMENTS.PATH'); 57 | 58 | foreach ($emailIdsWithAttachments as $emailId) { 59 | Storage::disk($storageDisk)->deleteDirectory($basePath . '/' . $emailId); 60 | } 61 | } 62 | }); 63 | } 64 | 65 | MailwebEmail::whereIn('id', array_keys($emailIdsToDeleteKeyedById))->delete(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Facades/MailWeb.php: -------------------------------------------------------------------------------- 1 | css = array_values(array_unique(array_merge($this->css, Arr::wrap($css)), SORT_REGULAR)); 24 | 25 | return $this; 26 | } 27 | 28 | return collect($this->css)->reduce(function ($carry, $css) { 29 | if ($css instanceof Htmlable) { 30 | return $carry . Str::finish($css->toHtml(), PHP_EOL); 31 | } else { 32 | if (($contents = @file_get_contents($css)) === false) { 33 | throw new RuntimeException("Unable to load Pulse dashboard CSS path [$css]."); 34 | } 35 | 36 | return $carry . "" . PHP_EOL; 37 | } 38 | }, ''); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Http/Controllers/MailWebController.php: -------------------------------------------------------------------------------- 1 | user())) { 32 | abort(Response::HTTP_FORBIDDEN, $message); 33 | } 34 | } 35 | 36 | /** 37 | * Display the Mail Web dashboard. 38 | */ 39 | public function index(): View 40 | { 41 | $this->authorizeMailWebAccess(); 42 | 43 | return view('mailweb::dashboard'); 44 | } 45 | 46 | /** 47 | * Fetch emails for the dashboard with pagination. 48 | */ 49 | public function fetchEmails(\Illuminate\Http\Request $request): JsonResponse 50 | { 51 | $this->authorizeMailWebAccess(); 52 | 53 | $perPage = $request->input('per_page', 25); 54 | $page = $request->input('page', 1); 55 | $search = $request->input('search'); 56 | 57 | $emails = MailwebEmail::query() 58 | ->when($search, fn ($q) => $q->search($search)) 59 | ->orderBy('created_at', 'desc') 60 | ->select([ 61 | 'id', 'from', 'to', 'cc', 'bcc', 'subject', 'body_text', 62 | 'read', 'share_enabled', 'created_at', 'updated_at', 63 | ]) 64 | ->withCount('attachments') 65 | ->paginate($perPage, ['*'], 'page', $page) 66 | ->through(function ($email) { 67 | return [ 68 | 'id' => $email->id, 69 | 'from' => $email->from, 70 | 'to' => $email->to, 71 | 'cc' => $email->cc, 72 | 'bcc' => $email->bcc, 73 | 'subject' => $email->subject, 74 | 'body_text' => Str::of($email->body_text)->limit(60), 75 | 'read' => $email->read, 76 | 'share_enabled' => $email->share_enabled, 77 | 'created_at' => $email->created_at, 78 | 'updated_at' => $email->updated_at, 79 | 'share_url' => $email->share_enabled ? route('mailweb.share', $email) : null, 80 | 'attachments_count' => $email->attachments_count, 81 | ]; 82 | }); 83 | 84 | return response()->json($emails); 85 | } 86 | 87 | /** 88 | * Show a specific email. 89 | */ 90 | public function show(MailwebEmail $mailwebEmail): View 91 | { 92 | $this->authorizeMailWebAccess(); 93 | 94 | abort_if( 95 | ! $mailwebEmail->share_enabled, 96 | Response::HTTP_FORBIDDEN, 97 | 'This email is not shared or available for viewing' 98 | ); 99 | 100 | return view('mailweb::email-share', [ 101 | 'email' => $mailwebEmail, 102 | ]); 103 | } 104 | 105 | /** 106 | * Fetch a single email with full content. 107 | */ 108 | public function fetchEmail(string $id): JsonResponse 109 | { 110 | $this->authorizeMailWebAccess(); 111 | 112 | $email = MailwebEmail::with('attachments')->findOrFail($id); 113 | 114 | // Mark the email as read 115 | if (! $email->read) { 116 | $email->read = 1; 117 | $email->save(); 118 | } 119 | 120 | return response()->json($email); 121 | } 122 | 123 | /** 124 | * Toggle the share_enabled status for an email. 125 | */ 126 | public function toggleShare(string $id): JsonResponse 127 | { 128 | $this->authorizeMailWebAccess(); 129 | 130 | $email = MailwebEmail::findOrFail($id); 131 | $email->share_enabled = ! $email->share_enabled; 132 | $email->save(); 133 | 134 | return response()->json([ 135 | 'success' => true, 136 | 'share_enabled' => $email->share_enabled, 137 | 'share_url' => $email->share_enabled ? route('mailweb.share', $email) : null, 138 | ]); 139 | } 140 | 141 | /** 142 | * Delete all emails from the system. 143 | */ 144 | public function deleteAll(): JsonResponse 145 | { 146 | $this->authorizeMailWebAccess(); 147 | 148 | // Check if delete all feature is enabled 149 | if (! config('MailWeb.MAILWEB_DELETE_ALL_ENABLED', false)) { 150 | return response()->json([ 151 | 'success' => false, 152 | 'message' => 'Delete all emails feature is disabled', 153 | ], 403); 154 | } 155 | 156 | try { 157 | // Delete all emails 158 | $count = MailwebEmail::query()->delete(); 159 | 160 | return response()->json([ 161 | 'success' => true, 162 | 'message' => 'All emails have been deleted successfully', 163 | 'count' => $count, 164 | ]); 165 | } catch (\Exception $e) { 166 | return response()->json([ 167 | 'success' => false, 168 | 'message' => 'Failed to delete all emails', 169 | 'error' => $e->getMessage(), 170 | ], 500); 171 | } 172 | } 173 | 174 | public function downloadAttachment(MailwebEmail $mailwebEmail, MailwebEmailAttachment $mailwebEmailAttachment) 175 | { 176 | $this->authorizeMailWebAccess(); 177 | 178 | return Storage::disk(config('MailWeb.MAILWEB_ATTACHMENTS.DISK'))->download($mailwebEmailAttachment->path); 179 | } 180 | 181 | public function delete($id): JsonResponse 182 | { 183 | $this->authorizeMailWebAccess(); 184 | try { 185 | $email = MailwebEmail::findOrFail($id); 186 | $email->delete(); 187 | 188 | return response()->json([ 189 | 'success' => true, 190 | 'message' => 'Email deleted successfully', 191 | ]); 192 | } catch (\Exception $e) { 193 | return response()->json([ 194 | 'success' => false, 195 | 'message' => 'Failed to delete email', 196 | 'error' => $e->getMessage(), 197 | ], 500); 198 | } 199 | } 200 | 201 | public function sendTestEmail(Request $request) 202 | { 203 | // Temporarily set mail driver to log 204 | $originalMailDriver = Config::get('mail.default'); 205 | Config::set('mail.default', 'log'); 206 | 207 | try { 208 | // Send notification to example@appoly.co.uk 209 | Notification::route('mail', 'example@appoly.co.uk') 210 | ->notify(new MailwebSampleNotification); 211 | 212 | // Reset mail driver 213 | Config::set('mail.default', $originalMailDriver); 214 | 215 | return new JsonResponse(['success' => true, 'message' => 'Test email sent to logs']); 216 | } catch (\Exception $e) { 217 | // Reset mail driver in case of error 218 | Config::set('mail.default', $originalMailDriver); 219 | 220 | return new JsonResponse( 221 | ['success' => false, 'message' => 'Failed to send test email', 'error' => $e->getMessage()], 222 | 500 223 | ); 224 | } 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/Http/Listeners/MailWebListener.php: -------------------------------------------------------------------------------- 1 | $this->getAddresses($event->message->getFrom()), 34 | 'to' => $this->getAddresses($event->message->getTo()), 35 | 'cc' => $this->getAddresses($event->message->getCc()), 36 | 'bcc' => $this->getAddresses($event->message->getBcc()), 37 | 'subject' => $event->message->getSubject(), 38 | 'body_html' => $event->message->getHtmlBody(), 39 | 'body_text' => $event->message->getTextBody(), 40 | 'read' => false, 41 | ]); 42 | 43 | foreach ($event->message->getAttachments() as $attachment) { 44 | if ($attachment instanceof \Symfony\Component\Mime\Part\DataPart) { 45 | // Extract attachment details 46 | $fileName = $attachment->getFilename(); 47 | $extension = pathinfo($fileName, PATHINFO_EXTENSION); 48 | $fileContent = $attachment->getBody(); 49 | $mimeType = $attachment->getMediaType() . '/' . $attachment->getMediaSubtype(); 50 | $fileSizeBytes = strlen($fileContent); 51 | 52 | // Store the original name regardless of whether we end up backing up the file 53 | $attachment = $mailwebEmail->attachments()->create([ 54 | 'name' => $fileName, 55 | 'size_bytes' => $fileSizeBytes, 56 | 'path' => null, 57 | ]); 58 | 59 | try { 60 | $storageDisk = config('MailWeb.MAILWEB_ATTACHMENTS.DISK'); 61 | // If the config is enabled, we store the file 62 | if ($storageDisk) { 63 | $path = config('MailWeb.MAILWEB_ATTACHMENTS.PATH') . '/' . $attachment->mailweb_email_id . '/' . $attachment->id . '.' . $extension; 64 | Storage::disk($storageDisk)->put( 65 | path: $path, 66 | contents: $fileContent, 67 | options: ['ContentType' => $mimeType] 68 | ); 69 | 70 | $attachment->update([ 71 | 'path' => $path, 72 | ]); 73 | } 74 | } catch (\Throwable $e) { 75 | // We don't want to fail the entire process, so just log the error and move on 76 | report($e); 77 | } 78 | } 79 | } 80 | }); 81 | } 82 | 83 | private function getAddresses(array $addresses): array 84 | { 85 | return collect($addresses)->map(function ($address) { 86 | return [ 87 | 'address' => $address->getAddress(), 88 | 'name' => $address->getName(), 89 | ]; 90 | })->toArray(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Http/Models/MailwebEmail.php: -------------------------------------------------------------------------------- 1 | 'json', 25 | 'to' => 'json', 26 | 'cc' => 'json', 27 | 'bcc' => 'json', 28 | ]; 29 | 30 | // Create this for route model bindings - it's identical to the attachments() relation below 31 | public function mailwebEmailAttachments() 32 | { 33 | return $this->hasMany(MailwebEmailAttachment::class); 34 | } 35 | 36 | public function attachments() 37 | { 38 | return $this->hasMany(MailwebEmailAttachment::class); 39 | } 40 | 41 | public function scopeSearch($query, $search) 42 | { 43 | return $query->where('subject', 'like', "%$search%") 44 | ->orWhere('body_text', 'like', "%$search%") 45 | ->orWhere('body_html', 'like', "%$search%"); 46 | } 47 | 48 | public function scopeShareEnabled($query) 49 | { 50 | return $query->where('share_enabled', true); 51 | } 52 | 53 | public function getSnippetAttribute() 54 | { 55 | // truncate $this->body_text to 100 characters with ... 56 | return Str::limit($this->body_text, 130, '...'); 57 | } 58 | 59 | public function getAttachmentCountAttribute() 60 | { 61 | return $this->attachments->count(); 62 | } 63 | 64 | public function getShareUrlAttribute() 65 | { 66 | return $this->share_enabled ? route('mailweb.share', $this) : null; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Http/Models/MailwebEmailAttachment.php: -------------------------------------------------------------------------------- 1 | belongsTo(MailwebEmail::class); 21 | } 22 | 23 | public function getHumanReadableSizeAttribute() 24 | { 25 | if ($this->size_bytes === null) { 26 | return 'Unknown'; 27 | } 28 | 29 | return Number::fileSize($this->size_bytes); 30 | } 31 | 32 | public function getFileUrlAttribute() 33 | { 34 | if ($this->path === null) { 35 | return null; 36 | } 37 | 38 | // get signed URL: 39 | return Storage::disk(config('MailWeb.MAILWEB_ATTACHMENTS.DISK'))->url($this->path); 40 | } 41 | 42 | public function getDownloadUrlAttribute() 43 | { 44 | if ($this->path === null) { 45 | return null; 46 | } 47 | 48 | return route('mailweb.download-attachment', [$this->mailwebEmail, $this]); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/MailWebServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom(__DIR__ . '/../config/config.php', 'MailWeb'); 17 | $this->app->register(MessageServiceProvider::class); 18 | 19 | $this->commands([ 20 | PruneMailwebMails::class, 21 | ]); 22 | 23 | $this->app->singleton('MailWeb', function () { 24 | return new MailWeb; 25 | }); 26 | } 27 | 28 | public function boot() 29 | { 30 | $this->registerMigrations(); 31 | $this->loadViewsFrom(__DIR__ . '/../resources/views', 'mailweb'); 32 | $this->registerRoutesMacro(); 33 | 34 | if ($this->app->runningInConsole()) { 35 | $this->publishes([ 36 | __DIR__ . '/../config/config.php' => config_path('MailWeb.php'), 37 | ], 'mailweb-config'); 38 | } 39 | } 40 | 41 | /** 42 | * Get the CSS or JS asset for the MailWeb dashboard. 43 | * 44 | * @param string $assetType Either 'css' or 'js' 45 | */ 46 | private static function getAsset(string $assetType): \Illuminate\Contracts\Support\Htmlable 47 | { 48 | $manifestPath = __DIR__ . '/../public/vendor/mailweb/.vite/manifest.json'; 49 | 50 | if (! file_exists($manifestPath)) { 51 | return new HtmlString(''); 52 | } 53 | 54 | $manifest = json_decode(file_get_contents($manifestPath), true); 55 | 56 | if (! $manifest) { 57 | return new HtmlString(''); 58 | } 59 | 60 | $assetKey = $assetType === 'css' ? 'style.css' : 'resources/js/app.js'; 61 | 62 | if (! isset($manifest[$assetKey]['file'])) { 63 | return new HtmlString(''); 64 | } 65 | 66 | $assetPath = __DIR__ . '/../public/vendor/mailweb/' . $manifest[$assetKey]['file']; 67 | 68 | if (! file_exists($assetPath)) { 69 | return new HtmlString(''); 70 | } 71 | 72 | $content = file_get_contents($assetPath); 73 | 74 | if ($assetType === 'css') { 75 | return new HtmlString(""); 76 | } else { 77 | $config = Js::from([ 78 | 'deleteAllEnabled' => config('MailWeb.MAILWEB_DELETE_ALL_ENABLED', false), 79 | 'sendSampleButton' => config('MailWeb.MAILWEB_SEND_SAMPLE_BUTTON', true), 80 | 'return' => [ 81 | 'appName' => config('MailWeb.MAILWEB_RETURN.APP_NAME') ?? config('app.name') ?? 'App', 82 | 'appUrl' => config('MailWeb.MAILWEB_RETURN.APP_URL') ?? '/', 83 | ], 84 | ]); 85 | 86 | return new HtmlString(<< 88 | window.mailwebConfig = {$config}; 89 | {$content} 90 | 91 | HTML); 92 | } 93 | } 94 | 95 | /** 96 | * Get the CSS for the MailWeb dashboard. 97 | */ 98 | public static function css(): \Illuminate\Contracts\Support\Htmlable 99 | { 100 | return self::getAsset('css'); 101 | } 102 | 103 | /** 104 | * Get the JS for the MailWeb dashboard. 105 | */ 106 | public static function js(): \Illuminate\Contracts\Support\Htmlable 107 | { 108 | return self::getAsset('js'); 109 | } 110 | 111 | /** 112 | * Register routes macro. 113 | */ 114 | protected function registerRoutesMacro() 115 | { 116 | $router = $this->app['router']; 117 | 118 | $router->macro('mailweb', function () use ($router) { 119 | $router->get('/mailweb', '\Appoly\MailWeb\Http\Controllers\MailWebController@index')->name('mailweb.index'); 120 | $router->get('/mailweb/emails', '\Appoly\MailWeb\Http\Controllers\MailWebController@fetchEmails')->name('mailweb.fetch'); 121 | $router->get('/mailweb/emails/{mailwebEmail}/attachment/{mailwebEmailAttachment}', '\Appoly\MailWeb\Http\Controllers\MailWebController@downloadAttachment')->name('mailweb.download-attachment')->scopeBindings(); 122 | $router->get('/mailweb/emails/{id}', '\Appoly\MailWeb\Http\Controllers\MailWebController@fetchEmail')->name('mailweb.fetch-email'); 123 | $router->post('/mailweb/emails/{id}/toggle-share', '\Appoly\MailWeb\Http\Controllers\MailWebController@toggleShare')->name('mailweb.toggle-share'); 124 | $router->delete('/mailweb/emails/delete-all', '\Appoly\MailWeb\Http\Controllers\MailWebController@deleteAll')->name('mailweb.delete-all'); 125 | $router->delete('/mailweb/emails/{id}', '\Appoly\MailWeb\Http\Controllers\MailWebController@delete')->name('mailweb.delete'); 126 | $router->get('/mailweb/share/{mailwebEmail}', '\Appoly\MailWeb\Http\Controllers\MailWebController@show')->name('mailweb.share'); 127 | $router->get('/mailweb/send-test-email', '\Appoly\MailWeb\Http\Controllers\MailWebController@sendTestEmail')->name('mailweb.send-test-email'); 128 | }); 129 | } 130 | 131 | /** 132 | * Register migrations. 133 | */ 134 | protected function registerMigrations() 135 | { 136 | if ($this->app->runningInConsole()) { 137 | $this->loadMigrationsFrom(__DIR__ . '/Migrations'); 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Migrations/0000_00_00_000000_create_mail_web_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 21 | $table->longText('email')->nullable(); 22 | $table->boolean('read')->default(false); 23 | $table->timestamps(); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | * 30 | * @return void 31 | */ 32 | public function down() 33 | { 34 | Schema::dropIfExists('mailweb_emails'); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /src/Migrations/0000_00_00_000001_alter_for_new_mail_web.php: -------------------------------------------------------------------------------- 1 | uuid('id')->primary(); 23 | $table->json('from')->nullable(); 24 | $table->json('to')->nullable(); 25 | $table->json('cc')->nullable(); 26 | $table->json('bcc')->nullable(); 27 | $table->string('subject')->nullable(); 28 | $table->longText('body_html')->nullable(); 29 | $table->longText('body_text')->nullable(); 30 | $table->boolean('read')->default(false); 31 | $table->boolean('share_enabled')->default(false); 32 | $table->timestamps(); 33 | }); 34 | } 35 | 36 | /** 37 | * Reverse the migrations. 38 | * 39 | * @return void 40 | */ 41 | public function down() 42 | { 43 | Schema::dropIfExists('mailweb_emails'); 44 | 45 | if (Schema::hasTable('mailweb_emails_archived')) { 46 | Schema::rename('mailweb_emails_archived', 'mailweb_emails'); 47 | } 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /src/Migrations/0000_00_00_000002_add_mail_web_attachment_table.php: -------------------------------------------------------------------------------- 1 | uuid('id')->primary(); 18 | $table->foreignUuid('mailweb_email_id')->constrained('mailweb_emails')->onDelete('cascade'); 19 | $table->string('name')->nullable(); 20 | $table->string('path')->nullable(); 21 | $table->timestamps(); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | * 28 | * @return void 29 | */ 30 | public function down() 31 | { 32 | Schema::dropIfExists('mailweb_email_attachments'); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /src/Migrations/0000_00_00_000003_add_size_to_mail_web_attachment_table.php: -------------------------------------------------------------------------------- 1 | integer('size_bytes')->nullable(); 18 | }); 19 | } 20 | 21 | /** 22 | * Reverse the migrations. 23 | * 24 | * @return void 25 | */ 26 | public function down() 27 | { 28 | Schema::table('mailweb_email_attachments', function (Blueprint $table) { 29 | $table->dropColumn('size_bytes'); 30 | }); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /src/Notifications/MailwebSampleNotification.php: -------------------------------------------------------------------------------- 1 | mails = [ 20 | [ 21 | 'id' => 'contact_form', 22 | 'subject' => 'New Contact Form Submission', 23 | 'body' => [ 24 | ['type' => 'line', 'content' => 'A new contact form submission has been received from John Doe.'], 25 | ['type' => 'line', 'content' => 'Details:'], 26 | ['type' => 'line', 'content' => 'Email: john.doe@example.com'], 27 | ['type' => 'line', 'content' => 'Phone: +1 (555) 123-4567'], 28 | ['type' => 'line', 'content' => 'Message: "I’m interested in your premium services. Could you share pricing details and consultation availability for next week?"'], 29 | ['type' => 'button', 'content' => 'View Full Message', 'url' => 'http://example.com/crm/contacts/123'], 30 | ['type' => 'line', 'content' => 'Received: March 7, 2025 at 10:15 AM'], 31 | ], 32 | ], 33 | [ 34 | 'id' => 'welcome', 35 | 'subject' => 'Welcome to the Platform', 36 | 'body' => [ 37 | ['type' => 'line', 'content' => 'Hello Jane,'], 38 | ['type' => 'line', 'content' => 'We’re pleased to welcome you to the platform. Your account is now active and ready for use.'], 39 | ['type' => 'line', 'content' => 'To get started:'], 40 | ['type' => 'line', 'content' => '• Update your profile to customize your experience'], 41 | ['type' => 'line', 'content' => '• Explore the available features and tools'], 42 | ['type' => 'line', 'content' => '• Connect with the developer community'], 43 | ['type' => 'button', 'content' => 'Begin Setup', 'url' => 'http://example.com/onboarding'], 44 | ['type' => 'line', 'content' => 'Need assistance? Reply to this email or visit our support portal.'], 45 | ['type' => 'line', 'content' => 'Regards,'], 46 | ], 47 | ], 48 | [ 49 | 'id' => 'password_reset', 50 | 'subject' => 'Password Reset Request', 51 | 'body' => [ 52 | ['type' => 'line', 'content' => 'Hello,'], 53 | ['type' => 'line', 'content' => 'A password reset request has been received for your account. If this wasn’t you, please disregard this email.'], 54 | ['type' => 'line', 'content' => 'This link expires in 60 minutes for security purposes.'], 55 | ['type' => 'button', 'content' => 'Reset Password', 'url' => 'http://example.com/reset-password?token=abc123'], 56 | ['type' => 'line', 'content' => 'If the button doesn’t work, use this URL:'], 57 | ['type' => 'line', 'content' => 'http://example.com/reset-password?token=abc123'], 58 | ['type' => 'line', 'content' => 'Request origin: IP 192.168.1.1, Chrome on Windows'], 59 | ['type' => 'line', 'content' => 'Suspicious activity? Contact support immediately.'], 60 | ], 61 | ], 62 | [ 63 | 'id' => 'order_confirmation', 64 | 'subject' => 'Order Confirmation #12345', 65 | 'body' => [ 66 | ['type' => 'line', 'content' => 'Dear Customer,'], 67 | ['type' => 'line', 'content' => 'Thank you for your order. We’ve received Order #12345 and it’s now in processing.'], 68 | ['type' => 'line', 'content' => 'Order Summary:'], 69 | ['type' => 'line', 'content' => 'Order Number: #12345'], 70 | ['type' => 'line', 'content' => 'Date: March 7, 2025'], 71 | ['type' => 'line', 'content' => 'Items:'], 72 | ['type' => 'line', 'content' => '• Plain Black T-Shirt - $49.99'], 73 | ['type' => 'line', 'content' => '• Red T-Shirt - $29.99'], 74 | ['type' => 'line', 'content' => 'Subtotal: $79.98'], 75 | ['type' => 'line', 'content' => 'Shipping: $5.99'], 76 | ['type' => 'line', 'content' => 'Tax: $8.60'], 77 | ['type' => 'line', 'content' => 'Total: $94.57'], 78 | ['type' => 'button', 'content' => 'Track Order', 'url' => 'http://example.com/orders/12345'], 79 | ['type' => 'line', 'content' => 'Expected to ship within 2 business days. Tracking details will follow upon shipment.'], 80 | ], 81 | ], 82 | [ 83 | 'id' => 'event_invitation', 84 | 'subject' => 'Invitation: Virtual Conference 2025', 85 | 'body' => [ 86 | ['type' => 'line', 'content' => 'Hello,'], 87 | ['type' => 'line', 'content' => 'You’re invited to join us at Virtual Conference ' . now()->addMonth(1)->format('Y') . ', a premier event for developers and tech professionals.'], 88 | ['type' => 'line', 'content' => 'Event Information:'], 89 | ['type' => 'line', 'content' => 'Date: ' . now()->addMonth(1)->format('F d, Y') . ' - ' . now()->addMonth(1)->addDays(2)->format('F d, Y')], 90 | ['type' => 'line', 'content' => 'Time: 9:00 AM - 5:00 PM (EST)'], 91 | ['type' => 'line', 'content' => 'Platform: Zoom'], 92 | ['type' => 'line', 'content' => 'Speakers:'], 93 | ['type' => 'line', 'content' => '• Jane Smith - CEO, Innovation Labs'], 94 | ['type' => 'line', 'content' => '• John Johnson - CTO, Tech Solutions'], 95 | ['type' => 'line', 'content' => '• Sarah Williams - AI Research Director'], 96 | ['type' => 'button', 'content' => 'Register Now', 'url' => 'http://example.com/conference/register'], 97 | ['type' => 'line', 'content' => 'Early registration deadline: ' . now()->addDays(10)->format('F d, Y') . '. Reserve your spot today.'], 98 | ], 99 | ], 100 | ]; 101 | 102 | if ($templateId) { 103 | foreach ($this->mails as $template) { 104 | if (isset($template['id']) && $template['id'] === $templateId) { 105 | $this->selectedTemplate = $template; 106 | break; 107 | } 108 | } 109 | } 110 | } 111 | 112 | public function via(object $notifiable): array 113 | { 114 | return ['mail']; 115 | } 116 | 117 | public function toMail(object $notifiable): MailMessage 118 | { 119 | $mailContent = $this->selectedTemplate ?? $this->mails[array_rand($this->mails, 1)]; 120 | 121 | $mail = (new MailMessage) 122 | ->subject($mailContent['subject']) 123 | ->greeting('Hello' . (isset($notifiable->name) ? ' ' . $notifiable->name : '') . ','); 124 | 125 | foreach ($mailContent['body'] as $body) { 126 | if ($body['type'] == 'line') { 127 | $mail->line($body['content']); 128 | } elseif ($body['type'] == 'button') { 129 | $mail->action($body['content'], $body['url']); 130 | } 131 | } 132 | 133 | // Add attachments based on template ID for testing variety 134 | switch ($mailContent['id']) { 135 | case 'contact_form': 136 | // Single attachment 137 | $mail->attachData('Contact form submission details for John Doe.', 'contact-details.txt', ['mime' => 'text/plain']); 138 | break; 139 | 140 | case 'welcome': 141 | $document = file_get_contents(__DIR__ . '/../../public/test-documents/onboarding-guide.pdf'); 142 | $mail->attachData($document, 'onboarding-guide.pdf', ['mime' => 'application/pdf']); 143 | break; 144 | 145 | case 'password_reset': 146 | // No attachments 147 | break; 148 | 149 | case 'order_confirmation': 150 | $productImage = file_get_contents(__DIR__ . '/../../public/test-documents/product.jpg'); 151 | $mail->attachData($productImage, 'product.jpg', ['mime' => 'image/jpeg']); 152 | $mail->attachData("order_id,product,price\n12345,Plain Black T-Shirt,$49.99\n12345,Red T-Shirt,$29.99", 'order-12345.csv', ['mime' => 'text/csv']); 153 | $mail->attachData('Order confirmation details for #12345.', 'confirmation.txt', ['mime' => 'text/plain']); 154 | break; 155 | 156 | case 'event_invitation': 157 | $document = file_get_contents(__DIR__ . '/../../public/test-documents/speaker-bios.docx'); 158 | $mail->attachData($document, 'speaker-bios.docx', ['mime' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']); 159 | break; 160 | } 161 | 162 | return $mail; 163 | } 164 | 165 | public function toArray(object $notifiable): array 166 | { 167 | return []; 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/Providers/MessageServiceProvider.php: -------------------------------------------------------------------------------- 1 | [ 13 | MailWebListener::class 14 | ] 15 | ]; 16 | 17 | /** 18 | * Register any events for your application. 19 | * 20 | * @return void 21 | */ 22 | public function boot() 23 | { 24 | parent::boot(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/MailWebTestCase.php: -------------------------------------------------------------------------------- 1 | setUpDatabase(); 15 | 16 | // $this->app->make(Factory::class)->load(__DIR__ . '/factories'); 17 | } 18 | 19 | protected function getEnvironmentSetUp($app) 20 | { 21 | // Setup default database to use sqlite :memory: 22 | $app['config']->set('database.default', 'testbench'); 23 | $app['config']->set('database.connections.testbench', [ 24 | 'driver' => 'sqlite', 25 | 'database' => ':memory:', 26 | 'prefix' => '', 27 | ]); 28 | } 29 | 30 | protected function getPackageProviders($app) 31 | { 32 | return [ 33 | MailWebServiceProvider::class, 34 | ]; 35 | } 36 | 37 | protected function setUpDatabase() 38 | { 39 | Schema::create('users', function (Blueprint $table) { 40 | $table->increments('id'); 41 | $table->string('name'); 42 | $table->string('email'); 43 | $table->string('password'); 44 | $table->string('remember_token'); 45 | $table->timestamps(); 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/Unit/RouteTest.php: -------------------------------------------------------------------------------- 1 | actingAs(factory(User::class)->create()); 13 | } 14 | 15 | /** @test */ 16 | public function a_user_can_see_mail_web() 17 | { 18 | $this->get('/mailweb') 19 | ->assertStatus(200); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/factories/UserFactory.php: -------------------------------------------------------------------------------- 1 | define(User::class, function (Faker\Generator $faker) { 6 | static $password; 7 | 8 | return [ 9 | 'name' => $faker->name, 10 | 'email' => $faker->unique()->safeEmail, 11 | 'password' => bcrypt('secret'), 12 | 'remember_token' => str_random(10), 13 | ]; 14 | }); 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "jsx": "preserve", 8 | "sourceMap": true, 9 | "resolveJsonModule": true, 10 | "esModuleInterop": true, 11 | "lib": ["ESNext", "DOM"], 12 | "skipLibCheck": true, 13 | "baseUrl": ".", 14 | "paths": { 15 | "@/*": ["resources/js/*"] 16 | } 17 | }, 18 | "include": ["resources/**/*.ts", "resources/**/*.d.ts", "resources/**/*.tsx", "resources/**/*.vue"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import vue from '@vitejs/plugin-vue'; 3 | import path from 'path'; 4 | 5 | export default defineConfig({ 6 | plugins: [vue()], 7 | publicDir: false, 8 | build: { 9 | outDir: 'public/vendor/mailweb', 10 | emptyOutDir: true, 11 | cssCodeSplit: false, 12 | rollupOptions: { 13 | input: 'resources/js/app.js', 14 | output: { 15 | entryFileNames: 'mailweb.[hash].js', 16 | chunkFileNames: 'mailweb-chunk.[hash].js', 17 | assetFileNames: (assetInfo) => { 18 | if (/\.css$/.test(assetInfo.name)) { 19 | return 'mailweb.[hash].css'; 20 | } 21 | return 'mailweb-[hash][extname]'; 22 | } 23 | } 24 | }, 25 | manifest: true, 26 | }, 27 | resolve: { 28 | alias: { 29 | '@': path.resolve(__dirname, 'resources/js') 30 | } 31 | } 32 | }); --------------------------------------------------------------------------------