From d80b1e5e4fc6e84e317fb57dd59b22da72964c55 Mon Sep 17 00:00:00 2001 From: Rephl3x Date: Tue, 3 Mar 2026 17:02:38 +1300 Subject: [PATCH] Update all email templates with uniform branded graphics --- .build_number | 2 +- backend/app/build_info.py | 4 +- backend/app/services/invite_email.py | 382 ++++++++++++++++++++------- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- 5 files changed, 293 insertions(+), 101 deletions(-) diff --git a/.build_number b/.build_number index a17e58e..5e67e6d 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -0303261629 +0303261702 diff --git a/backend/app/build_info.py b/backend/app/build_info.py index 887a7e6..af83eb2 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,2 +1,2 @@ -BUILD_NUMBER = "0303261629" -CHANGELOG = '2026-03-03|Add SMTP receipt logging for Exchange relay tracing\n2026-03-03|Fix shared request access and Jellyfin-ready pipeline status\n2026-03-03|Process 1 build 0303261507\n2026-03-03|Improve SQLite batching and diagnostics visibility\n2026-03-03|Add login page visibility controls\n2026-03-03|Hotfix: expand landing-page search to all requests\n2026-03-02|Hotfix: add logged-out password reset flow\n2026-03-02|Process 1 build 0203261953\n2026-03-02|Process 1 build 0203261610\n2026-03-02|Process 1 build 0203261608\n2026-03-02|Add dedicated profile invites page and fix mobile admin layout\n2026-03-01|Persist Seerr media failure suppression and reduce sync error noise\n2026-03-01|Add repository line ending policy\n2026-03-01|Finalize diagnostics, logging controls, and email test support\n2026-03-01|Add invite email templates and delivery workflow\n2026-02-28|Finalize dev-1.3 upgrades and Seerr updates\n2026-02-27|admin docs and layout refresh, build 2702261314\n2026-02-27|Build 2702261153: fix jellyfin sync user visibility\n2026-02-26|Build 2602262241: live request page updates\n2026-02-26|Build 2602262204\n2026-02-26|Build 2602262159: restore jellyfin-first user source\n2026-02-26|Build 2602262049: split magent settings and harden local login\n2026-02-26|Build 2602262030: add magent settings and hardening\n2026-02-26|Build 2602261731: fix user resync after nuclear wipe\n2026-02-26|Build 2602261717: master invite policy and self-service invite controls\n2026-02-26|Build 2602261636: self-service invites and count fixes\n2026-02-26|Build 2602261605: invite trace and cross-system user lifecycle\n2026-02-26|Build 2602261536: refine invite layouts and tighten UI\n2026-02-26|Build 2602261523: live updates, invite cleanup and nuclear resync\n2026-02-26|Build 2602261442: tidy users and invite layouts\n2026-02-26|Build 2602261409: unify invite management controls\n2026-02-26|Build 2602260214: invites profiles and expiry admin controls\n2026-02-26|Build 2602260022: enterprise UI refresh and users bulk auto-search\n2026-02-25|Build 2502262321: fix auto-search quality and per-user toggle\n2026-02-02|Build 0202261541: allow FQDN service URLs\n2026-01-30|Build 3001262148: single container\n2026-01-29|Build 2901262244: format changelog\n2026-01-29|Build 2901262240: cache users\n2026-01-29|Tidy full changelog\n2026-01-29|Update full changelog\n2026-01-29|Bake build number and changelog\n2026-01-29|Hardcode build number in backend\n2026-01-29|release: 2901262102\n2026-01-29|release: 2901262044\n2026-01-29|release: 2901262036\n2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539)\n2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524)\n2026-01-27|Add service test buttons (build 271261335)\n2026-01-27|Bump build number (process 2) 271261322\n2026-01-27|Add cache load spinner (build 271261238)\n2026-01-27|Fix snapshot title fallback (build 271261228)\n2026-01-27|Fix request titles in snapshots (build 271261219)\n2026-01-27|Bump build number to 271261202\n2026-01-27|Clarify request sync settings (build 271261159)\n2026-01-27|Fix backend cache stats import (build 271261149)\n2026-01-27|Improve cache stats performance (build 271261145)\n2026-01-27|Add cache control artwork stats\n2026-01-26|Fix sync progress bar animation\n2026-01-26|Fix cache title hydration\n2026-01-25|Build 2501262041\n2026-01-25|Harden request cache titles and cache-only reads\n2026-01-25|Serve bundled branding assets by default\n2026-01-25|Seed branding logo from bundled assets\n2026-01-25|Tidy request sync controls\n2026-01-25|Add Jellyfin login cache and admin-only stats\n2026-01-25|Add user stats and activity tracking\n2026-01-25|Move account actions into avatar menu\n2026-01-25|Improve mobile header layout\n2026-01-25|Automate build number tagging and sync\n2026-01-25|Add site banner, build number, and changelog\n2026-01-24|Improve request handling and qBittorrent categories\n2026-01-24|Map Prowlarr releases to Arr indexers for manual grab\n2026-01-24|Clarify how-it-works steps and fixes\n2026-01-24|Document fix buttons in how-it-works\n2026-01-24|Route grabs through Sonarr/Radarr only\n2026-01-23|Use backend branding assets for logo and favicon\n2026-01-23|Copy public assets into frontend image\n2026-01-23|Fix backend Dockerfile paths for root context\n2026-01-23|Add Docker Hub compose override\n2026-01-23|Remove password fields from users page\n2026-01-23|Use bundled branding assets\n2026-01-23|Add default branding assets when missing\n2026-01-23|Show available status on landing when in Jellyfin\n2026-01-23|Fix cache titles and move feedback link\n2026-01-23|Add feedback form and webhook\n2026-01-23|Hide header actions when signed out\n2026-01-23|Fallback manual grab to qBittorrent\n2026-01-23|Split search actions and improve download options\n2026-01-23|Fix cache titles via Jellyseerr media lookup\n2026-01-22|Update README with Docker-first guide\n2026-01-22|Update README\n2026-01-22|Ignore build artifacts\n2026-01-22|Initial commit' +BUILD_NUMBER = "0303261702" +CHANGELOG = '2026-03-03|Add branded HTML email templates\n2026-03-03|Add SMTP receipt logging for Exchange relay tracing\n2026-03-03|Fix shared request access and Jellyfin-ready pipeline status\n2026-03-03|Process 1 build 0303261507\n2026-03-03|Improve SQLite batching and diagnostics visibility\n2026-03-03|Add login page visibility controls\n2026-03-03|Hotfix: expand landing-page search to all requests\n2026-03-02|Hotfix: add logged-out password reset flow\n2026-03-02|Process 1 build 0203261953\n2026-03-02|Process 1 build 0203261610\n2026-03-02|Process 1 build 0203261608\n2026-03-02|Add dedicated profile invites page and fix mobile admin layout\n2026-03-01|Persist Seerr media failure suppression and reduce sync error noise\n2026-03-01|Add repository line ending policy\n2026-03-01|Finalize diagnostics, logging controls, and email test support\n2026-03-01|Add invite email templates and delivery workflow\n2026-02-28|Finalize dev-1.3 upgrades and Seerr updates\n2026-02-27|admin docs and layout refresh, build 2702261314\n2026-02-27|Build 2702261153: fix jellyfin sync user visibility\n2026-02-26|Build 2602262241: live request page updates\n2026-02-26|Build 2602262204\n2026-02-26|Build 2602262159: restore jellyfin-first user source\n2026-02-26|Build 2602262049: split magent settings and harden local login\n2026-02-26|Build 2602262030: add magent settings and hardening\n2026-02-26|Build 2602261731: fix user resync after nuclear wipe\n2026-02-26|Build 2602261717: master invite policy and self-service invite controls\n2026-02-26|Build 2602261636: self-service invites and count fixes\n2026-02-26|Build 2602261605: invite trace and cross-system user lifecycle\n2026-02-26|Build 2602261536: refine invite layouts and tighten UI\n2026-02-26|Build 2602261523: live updates, invite cleanup and nuclear resync\n2026-02-26|Build 2602261442: tidy users and invite layouts\n2026-02-26|Build 2602261409: unify invite management controls\n2026-02-26|Build 2602260214: invites profiles and expiry admin controls\n2026-02-26|Build 2602260022: enterprise UI refresh and users bulk auto-search\n2026-02-25|Build 2502262321: fix auto-search quality and per-user toggle\n2026-02-02|Build 0202261541: allow FQDN service URLs\n2026-01-30|Build 3001262148: single container\n2026-01-29|Build 2901262244: format changelog\n2026-01-29|Build 2901262240: cache users\n2026-01-29|Tidy full changelog\n2026-01-29|Update full changelog\n2026-01-29|Bake build number and changelog\n2026-01-29|Hardcode build number in backend\n2026-01-29|release: 2901262102\n2026-01-29|release: 2901262044\n2026-01-29|release: 2901262036\n2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539)\n2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524)\n2026-01-27|Add service test buttons (build 271261335)\n2026-01-27|Bump build number (process 2) 271261322\n2026-01-27|Add cache load spinner (build 271261238)\n2026-01-27|Fix snapshot title fallback (build 271261228)\n2026-01-27|Fix request titles in snapshots (build 271261219)\n2026-01-27|Bump build number to 271261202\n2026-01-27|Clarify request sync settings (build 271261159)\n2026-01-27|Fix backend cache stats import (build 271261149)\n2026-01-27|Improve cache stats performance (build 271261145)\n2026-01-27|Add cache control artwork stats\n2026-01-26|Fix sync progress bar animation\n2026-01-26|Fix cache title hydration\n2026-01-25|Build 2501262041\n2026-01-25|Harden request cache titles and cache-only reads\n2026-01-25|Serve bundled branding assets by default\n2026-01-25|Seed branding logo from bundled assets\n2026-01-25|Tidy request sync controls\n2026-01-25|Add Jellyfin login cache and admin-only stats\n2026-01-25|Add user stats and activity tracking\n2026-01-25|Move account actions into avatar menu\n2026-01-25|Improve mobile header layout\n2026-01-25|Automate build number tagging and sync\n2026-01-25|Add site banner, build number, and changelog\n2026-01-24|Improve request handling and qBittorrent categories\n2026-01-24|Map Prowlarr releases to Arr indexers for manual grab\n2026-01-24|Clarify how-it-works steps and fixes\n2026-01-24|Document fix buttons in how-it-works\n2026-01-24|Route grabs through Sonarr/Radarr only\n2026-01-23|Use backend branding assets for logo and favicon\n2026-01-23|Copy public assets into frontend image\n2026-01-23|Fix backend Dockerfile paths for root context\n2026-01-23|Add Docker Hub compose override\n2026-01-23|Remove password fields from users page\n2026-01-23|Use bundled branding assets\n2026-01-23|Add default branding assets when missing\n2026-01-23|Show available status on landing when in Jellyfin\n2026-01-23|Fix cache titles and move feedback link\n2026-01-23|Add feedback form and webhook\n2026-01-23|Hide header actions when signed out\n2026-01-23|Fallback manual grab to qBittorrent\n2026-01-23|Split search actions and improve download options\n2026-01-23|Fix cache titles via Jellyseerr media lookup\n2026-01-22|Update README with Docker-first guide\n2026-01-22|Update README\n2026-01-22|Ignore build artifacts\n2026-01-22|Initial commit' diff --git a/backend/app/services/invite_email.py b/backend/app/services/invite_email.py index ff9b81e..53e0312 100644 --- a/backend/app/services/invite_email.py +++ b/backend/app/services/invite_email.py @@ -136,6 +136,108 @@ TEMPLATE_PRESENTATION: Dict[str, Dict[str, str]] = { }, } + +def _build_email_stat_card(label: str, value: str, detail: str = "") -> str: + detail_html = ( + f"
" + f"{html.escape(detail)}
" + if detail + else "" + ) + return ( + "
" + f"
{html.escape(label)}
" + f"
" + f"{html.escape(value)}
" + f"{detail_html}" + "
" + ) + + +def _build_email_stat_grid(cards: list[str]) -> str: + if not cards: + return "" + rows: list[str] = [] + for index in range(0, len(cards), 2): + left = cards[index] + right = cards[index + 1] if index + 1 < len(cards) else "" + rows.append( + "" + f"{left}" + f"{right}" + "" + ) + return ( + "" + f"{''.join(rows)}" + "
" + ) + + +def _build_email_list(items: list[str], *, ordered: bool = False) -> str: + tag = "ol" if ordered else "ul" + marker = "padding-left:20px;" if ordered else "padding-left:18px;" + rendered_items = "".join( + f"
  • {html.escape(item)}
  • " for item in items if item + ) + return ( + f"<{tag} style=\"margin:0; {marker} color:#dbe5ff; line-height:1.8; font-size:14px;\">" + f"{rendered_items}" + f"" + ) + + +def _build_email_panel(title: str, body_html: str, *, variant: str = "neutral") -> str: + styles = { + "neutral": { + "background": "#101726", + "border": "rgba(255,255,255,0.08)", + "eyebrow": "#9aa3b8", + "text": "#dbe5ff", + }, + "brand": { + "background": "#101726", + "border": "rgba(59,130,246,0.22)", + "eyebrow": "#9dbfff", + "text": "#dbe5ff", + }, + "success": { + "background": "#122016", + "border": "rgba(34,197,94,0.24)", + "eyebrow": "#9de7b5", + "text": "#d9f9e4", + }, + "warning": { + "background": "#241814", + "border": "rgba(251,146,60,0.34)", + "eyebrow": "#fbbd7b", + "text": "#ffe0ba", + }, + "danger": { + "background": "#251418", + "border": "rgba(239,68,68,0.32)", + "eyebrow": "#ff9b9b", + "text": "#ffd0d0", + }, + }.get(variant, { + "background": "#101726", + "border": "rgba(255,255,255,0.08)", + "eyebrow": "#9aa3b8", + "text": "#dbe5ff", + }) + return ( + f"
    " + f"
    {html.escape(title)}
    " + f"
    {body_html}
    " + "
    " + ) + + DEFAULT_TEMPLATES: Dict[str, Dict[str, str]] = { "invited": { "subject": "{{app_name}} invite for {{recipient_email}}", @@ -156,31 +258,40 @@ DEFAULT_TEMPLATES: Dict[str, Dict[str, str]] = { "
    " "A new invitation has been prepared for {{recipient_email}}. Use the details below to sign up." "
    " - "" - "" - "" - "" - "" - "" - "" - "" - "" - "
    " - "
    Invite code
    " - "
    {{invite_code}}
    " - "
    " - "
    Invited by
    " - "
    {{inviter_username}}
    " - "
    " - "
    Invite label
    " - "
    {{invite_label}}
    " - "
    " - "
    Access window
    " - "
    {{invite_expires_at}}
    " - "
    Remaining uses: {{invite_remaining_uses}}
    " - "
    " - "
    {{invite_description}}
    " - "
    {{message}}
    " + + _build_email_stat_grid( + [ + _build_email_stat_card("Invite code", "{{invite_code}}"), + _build_email_stat_card("Invited by", "{{inviter_username}}"), + _build_email_stat_card("Invite label", "{{invite_label}}"), + _build_email_stat_card( + "Access window", + "{{invite_expires_at}}", + "Remaining uses: {{invite_remaining_uses}}", + ), + ] + ) + + _build_email_panel( + "Invitation details", + "
    {{invite_description}}
    ", + variant="brand", + ) + + _build_email_panel( + "Message from admin", + "
    {{message}}
    ", + variant="neutral", + ) + + _build_email_panel( + "What happens next", + _build_email_list( + [ + "Open the invite link and complete the signup flow.", + "Sign in using the shared credentials for Magent and Seerr.", + "Use the How it works page if you want a quick overview first.", + ], + ordered=True, + ), + variant="neutral", + ) ), }, "welcome": { @@ -197,27 +308,31 @@ DEFAULT_TEMPLATES: Dict[str, Dict[str, str]] = { "
    " "Your account is live and ready to use. Everything below mirrors the current site behavior." "
    " - "" - "" - "" - "" - "" - "
    " - "
    Username
    " - "
    {{username}}
    " - "
    " - "
    Role
    " - "
    {{role}}
    " - "
    " - "
    " - "
    What to do next
    " - "
      " - "
    1. Open Magent and sign in using your shared credentials.
    2. " - "
    3. Search or review requests without refreshing every page.
    4. " - "
    5. Use the invite tools in your profile if your account allows it.
    6. " - "
    " - "
    " - "
    {{message}}
    " + + _build_email_stat_grid( + [ + _build_email_stat_card("Username", "{{username}}"), + _build_email_stat_card("Role", "{{role}}"), + _build_email_stat_card("Magent", "{{app_url}}"), + _build_email_stat_card("Guides", "{{how_it_works_url}}"), + ] + ) + + _build_email_panel( + "What to do next", + _build_email_list( + [ + "Open Magent and sign in using your shared credentials.", + "Search all requests or review your own activity without refreshing the page.", + "Use the invite tools in your profile if your account allows it.", + ], + ordered=True, + ), + variant="success", + ) + + _build_email_panel( + "Additional notes", + "
    {{message}}
    ", + variant="neutral", + ) ), }, "warning": { @@ -233,12 +348,35 @@ DEFAULT_TEMPLATES: Dict[str, Dict[str, str]] = { "
    " "Please review this account notice carefully. This message was sent by an administrator." "
    " - "
    " - "
    Reason
    " - "
    {{reason}}
    " - "
    " - "
    {{message}}
    " - "
    If you need help or think this was sent in error, contact the site administrator.
    " + + _build_email_stat_grid( + [ + _build_email_stat_card("Account", "{{username}}"), + _build_email_stat_card("Role", "{{role}}"), + _build_email_stat_card("Application", "{{app_name}}"), + _build_email_stat_card("Support", "{{how_it_works_url}}"), + ] + ) + + _build_email_panel( + "Reason", + "
    {{reason}}
    ", + variant="warning", + ) + + _build_email_panel( + "Administrator note", + "
    {{message}}
    ", + variant="neutral", + ) + + _build_email_panel( + "What to do next", + _build_email_list( + [ + "Review the note above and confirm you understand what needs to change.", + "If you need help, reply through your usual support path or contact an administrator.", + "Keep this email for reference until the matter is resolved.", + ] + ), + variant="neutral", + ) ), }, "banned": { @@ -253,15 +391,35 @@ DEFAULT_TEMPLATES: Dict[str, Dict[str, str]] = { "
    " "Your account access has changed. Review the details below." "
    " - "" - "" - "" - "" - "
    " - "
    Reason
    " - "
    {{reason}}
    " - "
    " - "
    {{message}}
    " + + _build_email_stat_grid( + [ + _build_email_stat_card("Account", "{{username}}"), + _build_email_stat_card("Status", "Restricted"), + _build_email_stat_card("Application", "{{app_name}}"), + _build_email_stat_card("Guidance", "{{how_it_works_url}}"), + ] + ) + + _build_email_panel( + "Reason", + "
    {{reason}}
    ", + variant="danger", + ) + + _build_email_panel( + "Administrator note", + "
    {{message}}
    ", + variant="neutral", + ) + + _build_email_panel( + "What this means", + _build_email_list( + [ + "Your access has been removed or restricted across the linked services.", + "If you believe this is incorrect, contact the site administrator directly.", + "Do not rely on old links or cached sessions after this change.", + ] + ), + variant="neutral", + ) ), }, } @@ -481,7 +639,7 @@ def build_invite_email_context( invite.get("created_by") if invite else (user.get("username") if user else None), "Admin", ), - "message": _normalize_display_text(message, ""), + "message": _normalize_display_text(message, "No additional note."), "reason": _normalize_display_text(reason, "Not specified"), "recipient_email": _normalize_display_text(resolved_recipient, "No email supplied"), "role": _normalize_display_text(user.get("role") if user else None, "user"), @@ -835,6 +993,13 @@ async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, st application_url = _normalize_display_text(runtime.magent_application_url, "Not configured") primary_url = application_url if application_url.lower().startswith(("http://", "https://")) else "" + smtp_target = f"{_normalize_display_text(runtime.magent_notify_email_smtp_host, 'Not configured')}:{int(runtime.magent_notify_email_smtp_port or 587)}" + security_mode = "SSL" if runtime.magent_notify_email_use_ssl else ("STARTTLS" if runtime.magent_notify_email_use_tls else "Plain SMTP") + auth_mode = "Authenticated" if ( + _normalize_display_text(runtime.magent_notify_email_smtp_username) + and _normalize_display_text(runtime.magent_notify_email_smtp_password) + ) else "No SMTP auth" + delivery_warning = smtp_email_delivery_warning() subject = f"{env_settings.app_name} email test" body_text = ( f"This is a test email from {env_settings.app_name}.\n\n" @@ -852,21 +1017,36 @@ async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, st "
    " "This is a live test email from Magent. If this renders correctly, the HTML template shell and SMTP handoff are both working." "
    " - "" - "" - "" - "" - "" - "
    " - "
    Build
    " - f"
    {html.escape(BUILD_NUMBER)}
    " - "
    " - "
    Application URL
    " - f"
    {html.escape(application_url)}
    " - "
    " - "
    " - "Use this test when changing SMTP settings, relay targets, or branding." - "
    " + + _build_email_stat_grid( + [ + _build_email_stat_card("Recipient", resolved_email), + _build_email_stat_card("Build", BUILD_NUMBER), + _build_email_stat_card("SMTP target", smtp_target), + _build_email_stat_card("Security", security_mode, auth_mode), + _build_email_stat_card("Application URL", application_url), + _build_email_stat_card("Template shell", "Branded HTML", "Logo, gradient, action buttons"), + ] + ) + + _build_email_panel( + "What this verifies", + _build_email_list( + [ + "Magent can build the HTML template shell correctly.", + "The configured SMTP route accepts and relays the message.", + "Branding, links, and build metadata are rendering consistently.", + ] + ), + variant="brand", + ) + + _build_email_panel( + "Delivery notes", + ( + f"
    {html.escape(delivery_warning)}
    " + if delivery_warning + else "Use this test when changing SMTP settings, relay targets, or branding." + ), + variant="warning" if delivery_warning else "neutral", + ) ), primary_label="Open Magent" if primary_url else "", primary_url=primary_url, @@ -933,24 +1113,36 @@ async def send_password_reset_email( f"
    " f"A password reset was requested for {html.escape(username)}." "
    " - "" - "" - "" - "" - "" - "
    " - "
    Account
    " - f"
    {html.escape(username)}
    " - "
    " - "
    Expires
    " - f"
    {html.escape(expires_at)}
    " - "
    " - f"
    " - f"This reset will update the password used for {html.escape(provider_label)}." - "
    " - "
    " - "If you did not request this reset, ignore this email. No changes will be applied until the reset link is opened and completed." - "
    " + + _build_email_stat_grid( + [ + _build_email_stat_card("Account", username), + _build_email_stat_card("Expires", expires_at), + _build_email_stat_card("Credentials updated", provider_label), + _build_email_stat_card("Delivery target", resolved_email), + ] + ) + + _build_email_panel( + "What will be updated", + f"This reset will update the password used for {html.escape(provider_label)}.", + variant="brand", + ) + + _build_email_panel( + "What happens next", + _build_email_list( + [ + "Open the reset link and choose a new password.", + "Complete the form before the expiry time shown above.", + "Use the new password the next time you sign in.", + ], + ordered=True, + ), + variant="neutral", + ) + + _build_email_panel( + "Safety note", + "If you did not request this reset, ignore this email. No changes will be applied until the reset link is opened and completed.", + variant="warning", + ) ), primary_label="Reset password", primary_url=reset_url, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 01e43f0..811a7b7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "magent-frontend", - "version": "0303261629", + "version": "0303261702", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "magent-frontend", - "version": "0303261629", + "version": "0303261702", "dependencies": { "next": "16.1.6", "react": "19.2.4", diff --git a/frontend/package.json b/frontend/package.json index 39bf8de..1868c03 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "magent-frontend", "private": true, - "version": "0303261629", + "version": "0303261702", "scripts": { "dev": "next dev", "build": "next build",