diff --git a/e2e/cypress/integration/signup.ts b/e2e/cypress/integration/signup.ts
new file mode 100644
index 000000000..9282cb200
--- /dev/null
+++ b/e2e/cypress/integration/signup.ts
@@ -0,0 +1,100 @@
+// --------- BEGIN RUNBOX LICENSE ---------
+// Copyright (C) 2016-2026 Runbox Solutions AS (runbox.com).
+//
+// This file is part of Runbox 7.
+//
+// Runbox 7 is free software: You can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the
+// Free Software Foundation, either version 3 of the License, or (at your
+// option) any later version.
+//
+// Runbox 7 is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+// General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Runbox 7. If not, see .
+// ---------- END RUNBOX LICENSE ----------
+
+///
+
+describe('Signup', () => {
+ beforeEach(() => {
+ cy.intercept('GET', '/signup?legacy=1&runbox7=1', {
+ statusCode: 200,
+ body: `
+
+
+
+
+
+ `,
+ headers: {
+ 'content-type': 'text/html',
+ },
+ }).as('legacySignup');
+
+ cy.intercept('GET', 'https://hcaptcha.com/1/api.js?render=explicit', {
+ statusCode: 200,
+ body: 'window.hcaptcha = { render: function() { return "test-widget"; } };',
+ headers: {
+ 'content-type': 'application/javascript',
+ },
+ }).as('hcaptchaScript');
+ });
+
+ it('should render the Angular signup page in the local mock-backed environment', () => {
+ cy.visit('/signup?runbox7=1');
+ cy.wait('@legacySignup');
+ cy.wait('@hcaptchaScript');
+
+ cy.location('pathname').should('eq', '/signup');
+ cy.location('search').should('contain', 'runbox7=1');
+ cy.contains('h1', 'Create a Runbox Account').should('exist');
+ cy.get('form.signup-form').should('have.attr', 'action', '/mail/signup');
+ cy.get('input[name="user"]').should('exist');
+ cy.get('input[name="first_name"]').should('exist');
+ cy.get('input[name="last_name"]').should('exist');
+ cy.get('input[name="password"]').should('exist');
+ cy.get('select[name="runboxDomain"]').find('option').should('have.length', 3);
+ cy.get('select[name="runboxDomain"]').find('option').then((options) => {
+ const domains = Array.from(options).map((option) => option.value);
+ expect(domains).to.include('runbox.com');
+ expect(domains).to.include('rbx.email');
+ });
+ cy.get('div.captcha-host').should('exist');
+ cy.contains('button.submit', 'Set up my Runbox account').should('exist');
+ });
+
+ it('should show the public trust and transparency content', () => {
+ cy.visit('/signup?runbox7=1');
+ cy.wait('@legacySignup');
+ cy.wait('@hcaptchaScript');
+
+ cy.get('header.signup-header .brand img').should('be.visible');
+ cy.get('header.signup-header .brand').should('not.contain', 'Runbox 7');
+
+ cy.contains('.hero-panel h2', 'Privacy by business model').should('exist');
+ cy.contains('.hero-panel h2', 'Hosted in Norway').should('exist');
+ cy.contains('.hero-panel h2', 'Sustainable and secure').should('exist');
+ cy.contains('.hero-panel h2', 'How the trial works').should('exist');
+
+ cy.contains('.hero-panel', 'customer email content is private').should('exist');
+ cy.contains('.form-section', 'default sender name recipients will see').should('exist');
+
+ cy.get('.info-chip').should('have.length.at.least', 3);
+ cy.contains('.field-label, .field small', 'Existing email address').should('exist');
+ cy.contains('.field-label, .field small', 'How did you hear about Runbox?').should('exist');
+
+ cy.contains('.form-actions button.submit', 'Set up my Runbox account').should('exist');
+ cy.contains('a', 'Use legacy signup page').should('not.exist');
+ });
+});
diff --git a/package.json b/package.json
index 5a2cf1c08..2e38704ba 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"build": "node src/build/pre-build.js && ng build --configuration production --base-href=/app/ runbox7; RES=$?; node src/build/post-build.js; exit $RES",
"policy": "node policy-tests/run-all.js",
"test": "ng test",
+ "test:signup:firefox": "ng test --watch=false --browsers Firefox --include src/app/signup/signup.component.spec.ts",
"lint": "ng lint",
"e2e": "ng e2e",
"start-e2e-server": "start-test mockserver 15000 start-use-mockserver",
diff --git a/src/app/aliases/aliases.lister.spec.ts b/src/app/aliases/aliases.lister.spec.ts
index 8a3ffb893..3fef53f94 100644
--- a/src/app/aliases/aliases.lister.spec.ts
+++ b/src/app/aliases/aliases.lister.spec.ts
@@ -17,10 +17,16 @@
// along with Runbox 7. If not, see .
// ---------- END RUNBOX LICENSE ----------
-import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
import { AliasesListerComponent } from './aliases.lister';
-import { MatLegacyDialogModule } from '@angular/material/legacy-dialog';
+import { MatLegacyButtonModule } from '@angular/material/legacy-button';
+import { MatLegacyCardModule } from '@angular/material/legacy-card';
+import { MatLegacyDialog as MatDialog, MatLegacyDialogModule } from '@angular/material/legacy-dialog';
+import { MatLegacyInputModule } from '@angular/material/legacy-input';
import { MatLegacySnackBarModule } from '@angular/material/legacy-snack-bar';
+import { MatLegacySelectModule } from '@angular/material/legacy-select';
+import { MatExpansionModule } from '@angular/material/expansion';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { RunboxWebmailAPI } from '../rmmapi/rbwebmail';
import { RMM } from '../rmm';
@@ -34,6 +40,7 @@ import { HttpClient } from '@angular/common/http';
describe('AliasesListerComponent', () => {
let component: AliasesListerComponent;
let fixture: ComponentFixture;
+ let dialog: MatDialog;
const DEFAULT_EMAIL = 'a.kalou@shadowcat.co.uk';
const ALLOWED_DOMAINS = ['runbox.com', 'shadowcat.co.uk'];
@@ -47,10 +54,16 @@ describe('AliasesListerComponent', () => {
TestBed.configureTestingModule({
imports: [
CommonModule,
+ FormsModule,
HttpClientTestingModule,
+ MatLegacyButtonModule,
+ MatLegacyCardModule,
MatLegacyCommonModule,
+ MatLegacyInputModule,
+ MatLegacySelectModule,
MatLegacySnackBarModule,
MatLegacyDialogModule,
+ MatExpansionModule,
NoopAnimationsModule,
],
providers: [
@@ -94,6 +107,7 @@ describe('AliasesListerComponent', () => {
});
fixture = TestBed.createComponent(AliasesListerComponent);
component = fixture.componentInstance;
+ dialog = TestBed.inject(MatDialog);
});
it('loads aliases through RMM', () => {
@@ -126,7 +140,7 @@ describe('AliasesListerComponent', () => {
expect(forwards.length).toBe(component.aliases.length, 'all forwards should be shown');
});
- it('sets the default email to the current users email', fakeAsync(() => {
+ it('sets the default email to the current users email', () => {
expect(component.defaultEmail).toBe(DEFAULT_EMAIL);
// spawn a modal
@@ -143,22 +157,21 @@ describe('AliasesListerComponent', () => {
// FIXME: doesn't work, value isn't set, probably because of ngModel
// expect(forwardTo.value)
// .toBe(DEFAULT_EMAIL, "Forward to should default to the user's email");
- }));
+ });
- it('dialog loads allowed domains', () => {
+ it('dialog loads allowed domains', async () => {
// spawn a modal
component.create();
fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
const modal =
fixture.nativeElement.nextSibling.querySelector('app-aliases-edit');
expect(modal).toBeTruthy();
- const domain: HTMLSelectElement =
- modal.querySelector('mat-select[name=\'domain\']');
-
- ALLOWED_DOMAINS.forEach(allowed_domain => {
- expect(domain.textContent).toContain(allowed_domain);
- });
+ const dialogRef = dialog.openDialogs[0];
+ expect(dialogRef).toBeTruthy();
+ expect(dialogRef.componentInstance.allowedDomains).toEqual(ALLOWED_DOMAINS);
});
});
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index bced66edb..278d4b93f 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -94,6 +94,7 @@ window.addEventListener('dragover', (event) => event.preventDefault());
window.addEventListener('drop', (event) => event.preventDefault());
const routes: Routes = [
+ { path: 'signup', loadChildren: () => import('./signup/signup.module').then(m => m.SignupModule) },
{
path: '',
canActivateChild: [RMMAuthGuardService],
@@ -136,7 +137,8 @@ const routes: Routes = [
{ path: 'domainregistration', component: DomainRegisterRedirectComponent },
]
},
- { path: 'login', component: LoginComponent }
+ { path: 'login', component: LoginComponent },
+ { path: '**', redirectTo: '' }
];
@NgModule({
@@ -213,4 +215,3 @@ export class AppModule {
);
}
}
-
diff --git a/src/app/common/preferences.service.spec.ts b/src/app/common/preferences.service.spec.ts
index de5eac85d..49d0de4c6 100644
--- a/src/app/common/preferences.service.spec.ts
+++ b/src/app/common/preferences.service.spec.ts
@@ -19,12 +19,12 @@
import { PreferencesService, DefaultPrefGroups, PreferencesResult } from './preferences.service';
import { ScreenSize } from '../mobile-query.service';
-import { of, Subject, firstValueFrom, Observable } from 'rxjs';
+import { of, Subject, ReplaySubject, firstValueFrom, Observable } from 'rxjs';
import { take } from 'rxjs/operators';
class MockStorageService {
private store = new Map();
- me: Observable = of({ uid: 42 });
+ uid: Observable = of(42);
get(key: string): Promise {
return Promise.resolve(this.store.get(key));
@@ -241,9 +241,9 @@ describe('PreferencesService', () => {
describe('Integration with StorageService uid AsyncSubject', () => {
it('should handle operations that depend on uid being loaded', async () => {
- const uidSubject = new Subject<{ uid: number }>();
+ const uidSubject = new ReplaySubject(1);
const uidStorage = {
- me: uidSubject.asObservable(),
+ uid: uidSubject.asObservable(),
get: (key: string) => mockStorage.get(key),
set: (key: string, value: any) => mockStorage.set(key, value)
} as any;
@@ -254,7 +254,7 @@ describe('PreferencesService', () => {
testService.set(DefaultPrefGroups.Desktop, 'uidTestKey', 'uidTestValue');
// Now emit uid
- uidSubject.next({ uid: 999 });
+ uidSubject.next(999);
uidSubject.complete();
await new Promise(resolve => setTimeout(resolve, 50));
diff --git a/src/app/compose/compose.component.scss b/src/app/compose/compose.component.scss
index 3b28d9c44..99540e515 100644
--- a/src/app/compose/compose.component.scss
+++ b/src/app/compose/compose.component.scss
@@ -1,6 +1,6 @@
-@import '../../../node_modules/@angular/material/theming';
+@use '@angular/material' as mat;
-$rmm-default-accent: mat-palette($mat-blue, 100, 50, 200);
+$rmm-default-accent: mat.define-palette(mat.$blue-palette, 100, 50, 200);
#messageTextArea {
box-sizing: border-box;
diff --git a/src/app/help/help.component.spec.ts b/src/app/help/help.component.spec.ts
index 78805b537..a8de86db8 100644
--- a/src/app/help/help.component.spec.ts
+++ b/src/app/help/help.component.spec.ts
@@ -18,6 +18,7 @@
// ---------- END RUNBOX LICENSE ----------
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
import { HelpComponent } from './help.component';
@@ -27,7 +28,8 @@ describe('HelpComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
- declarations: [ HelpComponent ]
+ declarations: [ HelpComponent ],
+ schemas: [ NO_ERRORS_SCHEMA ]
})
.compileComponents();
});
diff --git a/src/app/login/login.component.html b/src/app/login/login.component.html
index c9d3d2425..bbc047927 100644
--- a/src/app/login/login.component.html
+++ b/src/app/login/login.component.html
@@ -21,7 +21,7 @@
diff --git a/src/app/mailviewer/singlemailviewer.component.spec.ts b/src/app/mailviewer/singlemailviewer.component.spec.ts
index d4bc44f93..13a60cda5 100644
--- a/src/app/mailviewer/singlemailviewer.component.spec.ts
+++ b/src/app/mailviewer/singlemailviewer.component.spec.ts
@@ -30,6 +30,7 @@ import { MatExpansionModule } from '@angular/material/expansion';
import { MatGridListModule } from '@angular/material/grid-list';
import { MatIcon, MatIconModule } from '@angular/material/icon';
import { MatIconTestingModule } from '@angular/material/icon/testing';
+import { MatLegacyListModule as MatListModule } from '@angular/material/legacy-list';
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
import { MatLegacyRadioModule as MatRadioModule } from '@angular/material/legacy-radio';
import { MatToolbarModule } from '@angular/material/toolbar';
@@ -105,6 +106,7 @@ describe('SingleMailViewerComponent', () => {
ResizerModule,
MatIconModule,
MatIconTestingModule,
+ MatListModule,
MatGridListModule,
MatToolbarModule,
MatTooltipModule,
diff --git a/src/app/signup/signup.component.html b/src/app/signup/signup.component.html
new file mode 100644
index 000000000..d8b5ee53e
--- /dev/null
+++ b/src/app/signup/signup.component.html
@@ -0,0 +1,362 @@
+
+
+
+
+
+
+
+
+
Sustainable and Private Email Hosting
+
Create a Runbox Account
+
+ Sustainable and secure email, your own domain options, and a 30-day free trial with no credit card required.
+
+
+ Your trial starts immediately. Before it ends, you can choose whether to upgrade and keep using the account.
+ Runbox is a paid email service, not an ad-funded one, and customer email content is private.
+
+
+
+
+
+
Sustainable and secure
+
Runbox combines modern webmail, custom domains, standard email access, strong spam filtering, and an emphasis on responsible operations.
+
+
+
Privacy by Design
+
You are the customer, not the product. Runbox is paid for by subscribers, not advertisers -- and your data is GDPR protected.
+
+
+
Hosted in Norway
+
Your service is operated from Norway by an independent email provider with a long-standing focus on privacy and user control.
+
+
+
How the trial works
+
Your mailbox is created immediately. Before the trial ends, you can decide whether to upgrade and continue using the account.
+
+
+
+
+
+
+
+
+
diff --git a/src/app/signup/signup.component.scss b/src/app/signup/signup.component.scss
new file mode 100644
index 000000000..f4b2f6854
--- /dev/null
+++ b/src/app/signup/signup.component.scss
@@ -0,0 +1,879 @@
+@use '@angular/material' as mat;
+@use '../../styles/rmm-theme-vars' as rmm;
+
+/* --------- BEGIN RUNBOX LICENSE ---------
+Copyright (C) 2016-2026 Runbox Solutions AS (runbox.com).
+
+This file is part of Runbox 7.
+
+Runbox 7 is free software: You can redistribute it and/or modify it
+under the terms of the GNU General Public License as published by the
+Free Software Foundation, either version 3 of the License, or (at your
+option) any later version.
+
+Runbox 7 is distributed in the hope that it will be useful, but
+WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with Runbox 7. If not, see .
+---------- END RUNBOX LICENSE ---------- */
+
+:host {
+ display: block;
+ min-height: 100vh;
+ color: rmm.$rmm-dark-background;
+ background:
+ radial-gradient(circle at top left, rgba(143, 198, 255, 0.34), transparent 34%),
+ radial-gradient(circle at bottom right, rgba(0, 88, 153, 0.18), transparent 28%),
+ linear-gradient(180deg, #f5f9fc 0%, #dfeaf3 100%);
+}
+
+.signup-shell {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.signup-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1rem;
+ padding: 1rem 1.5rem;
+ background-image: linear-gradient(
+ 145deg,
+ mat.get-color-from-palette(rmm.$rmm-default-primary),
+ rmm.$rmm-dark-background
+ );
+ color: #fff;
+ box-shadow: 0 8px 20px rgba(0, 25, 46, 0.12);
+}
+
+.brand {
+ display: inline-flex;
+ align-items: center;
+ color: inherit;
+ text-decoration: none;
+}
+
+.brand img {
+ height: 34px;
+ width: auto;
+}
+
+.header-tagline {
+ margin: 0;
+ color: rgba(255, 255, 255, 0.9);
+ font-size: 1rem;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+}
+
+.footer-links a:hover {
+ text-decoration: underline;
+}
+
+.signup-main {
+ width: min(1380px, calc(100% - 2rem));
+ margin: 0 auto;
+ padding: 1.5rem 0 2rem;
+ display: grid;
+ grid-template-columns: minmax(280px, 420px) minmax(0, 1fr);
+ gap: 1.5rem;
+ align-items: start;
+ flex: 1 0 auto;
+}
+
+.hero-panel,
+.form-panel {
+ border: 1px solid rgba(112, 145, 176, 0.28);
+ border-radius: 8px;
+ overflow: hidden;
+ box-shadow: 0 18px 48px rgba(9, 33, 58, 0.1);
+}
+
+.hero-panel {
+ display: grid;
+ background: linear-gradient(
+ 180deg,
+ mat.get-color-from-palette(rmm.$rmm-default-primary) 0%,
+ rmm.$rmm-dark-background 100%
+ );
+ color: #fff;
+}
+
+.hero-copy {
+ padding: 2rem 1.75rem 1.5rem;
+}
+
+.eyebrow {
+ margin: 0 0 0.75rem;
+ font-size: 0.78rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: rgba(255, 255, 255, 0.72);
+}
+
+.hero-copy h1 {
+ margin: 0;
+ font-size: clamp(2rem, 3vw, 3.2rem);
+ line-height: 1.02;
+}
+
+.hero-text {
+ margin: 1rem 0 0;
+ font-size: 1.04rem;
+ line-height: 1.6;
+ color: rgba(255, 255, 255, 0.88);
+}
+
+.hero-subtext {
+ margin: 1rem 0 0;
+ max-width: 40rem;
+ font-size: 0.96rem;
+ line-height: 1.6;
+ color: rgba(255, 255, 255, 0.78);
+}
+
+.hero-notes {
+ display: grid;
+ gap: 0;
+ background: transparent;
+}
+
+.note {
+ padding: 1.15rem 1.75rem;
+ background: transparent;
+ border-top: 1px solid rgba(255, 255, 255, 0.14);
+}
+
+.note h2 {
+ margin: 0 0 0.4rem;
+ font-size: 1rem;
+}
+
+.note p {
+ margin: 0;
+ line-height: 1.5;
+ color: rgba(255, 255, 255, 0.78);
+}
+
+.form-panel {
+ background: rgba(255, 255, 255, 0.92);
+ backdrop-filter: blur(4px);
+}
+
+.form-heading {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 0;
+ align-items: center;
+ padding: 1.4rem 1.5rem;
+ border-bottom: 1px solid rmm.$rmm-gray;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(240, 246, 252, 0.92));
+}
+
+.form-heading h2 {
+ margin: 0;
+ font-size: 1.7rem;
+}
+
+.form-heading p {
+ margin: 0.35rem 0 0;
+ color: #486175;
+ line-height: 1.5;
+}
+
+.signup-form {
+ padding: 1.4rem 1.5rem 1.6rem;
+}
+
+.form-section + .form-section {
+ margin-top: 1.35rem;
+}
+
+.form-section {
+ padding-top: 1.35rem;
+ border-top: 1px solid #dde7f0;
+}
+
+.form-section:first-child {
+ padding-top: 0;
+ border-top: 0;
+}
+
+.section-head {
+ margin-bottom: 0.9rem;
+}
+
+.section-head h3 {
+ margin: 0;
+ font-size: 1.08rem;
+}
+
+.section-head p {
+ margin: 0.28rem 0 0;
+ color: #5a7187;
+ line-height: 1.5;
+}
+
+.choice-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.85rem;
+}
+
+.choice-card {
+ display: grid;
+ gap: 0.32rem;
+ padding: 1rem 1rem 1rem 2.8rem;
+ position: relative;
+ border: 1px solid #bfd0e1;
+ border-radius: 8px;
+ background: #f8fbfe;
+ cursor: pointer;
+}
+
+.choice-card input[type='radio'] {
+ position: absolute;
+ top: 1.08rem;
+ left: 1rem;
+ margin: 0;
+}
+
+.choice-card.active {
+ border-color: mat.get-color-from-palette(rmm.$rmm-default-primary);
+ background: linear-gradient(180deg, #eef7ff 0%, #f8fbff 100%);
+ box-shadow: inset 0 0 0 1px rgba(0, 104, 183, 0.18);
+}
+
+.choice-title {
+ font-weight: 700;
+}
+
+.choice-copy {
+ color: #587085;
+ line-height: 1.45;
+ font-weight: 400;
+}
+
+.field-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.9rem 1rem;
+}
+
+.field-grid.single,
+.field.field-wide {
+ grid-column: 1 / -1;
+}
+
+.field {
+ display: grid;
+ gap: 0.38rem;
+ align-content: start;
+}
+
+.field > span {
+ font-weight: 700;
+}
+
+.password-input-wrap {
+ position: relative;
+ display: block;
+}
+
+.field-label {
+ display: inline-flex;
+ align-items: center;
+ width: fit-content;
+ max-width: 100%;
+}
+
+.field-label-gap {
+ margin-top: 0.15rem;
+}
+
+.field-label-text {
+ display: inline;
+}
+
+.field-help-row {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ width: fit-content;
+ max-width: 100%;
+}
+
+.domain-choice-field {
+ padding-top: 0.15rem;
+}
+
+.info-trigger {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ width: fit-content;
+ max-width: 100%;
+}
+
+.field > small {
+ color: #637b90;
+ font-size: 0.82rem;
+}
+
+.field.is-invalid > span,
+.checkbox-row.is-invalid span {
+ color: #a12020;
+}
+
+.info-chip {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.15rem;
+ height: 1.15rem;
+ padding: 0;
+ border: 0;
+ border-radius: 999px;
+ background: mat.get-color-from-palette(rmm.$rmm-default-highlight);
+ color: mat.get-color-from-palette(rmm.$rmm-default-primary);
+ font: inherit;
+ font-size: 0.76rem;
+ font-weight: 700;
+ line-height: 1;
+ cursor: help;
+}
+
+.info-popover {
+ position: absolute;
+ left: 0;
+ top: calc(100% + 0.55rem);
+ z-index: 5;
+ width: 18rem;
+ max-width: min(18rem, calc(100vw - 5rem));
+ padding: 0.7rem 0.8rem;
+ border-radius: 8px;
+ background: rmm.$rmm-dark-background;
+ color: #fff;
+ font-size: 0.82rem;
+ font-weight: 400;
+ line-height: 1.5;
+ text-align: left;
+ box-shadow: 0 16px 32px rgba(9, 33, 58, 0.22);
+ opacity: 0;
+ pointer-events: none;
+ transform: translate(0, 0.2rem);
+ transition: opacity 120ms ease, transform 120ms ease;
+}
+
+.info-trigger-end .info-popover {
+ left: auto;
+ right: 0;
+}
+
+.info-popover::before {
+ content: '';
+ position: absolute;
+ left: 0.95rem;
+ top: -0.35rem;
+ width: 0.7rem;
+ height: 0.7rem;
+ background: rmm.$rmm-dark-background;
+ transform: rotate(45deg);
+}
+
+.info-trigger-end .info-popover::before {
+ left: auto;
+ right: 0.95rem;
+}
+
+.info-trigger:hover .info-popover,
+.info-trigger:focus-within .info-popover {
+ opacity: 1;
+ transform: translate(0, 0);
+}
+
+.info-chip:focus-visible {
+ outline: 2px solid mat.get-color-from-palette(rmm.$rmm-default-primary);
+ outline-offset: 2px;
+}
+
+.field input,
+.field select {
+ width: 100%;
+ min-width: 0;
+ padding: 0.78rem 0.9rem;
+ border: 1px solid rmm.$rmm-default-dark-gray;
+ border-radius: 8px;
+ background: #fff;
+ font: inherit;
+ color: rmm.$rmm-dark-background;
+ box-sizing: border-box;
+}
+
+.password-input-wrap input {
+ padding-right: 5.5rem;
+}
+
+.password-toggle {
+ position: absolute;
+ top: 50%;
+ right: 0.7rem;
+ transform: translateY(-50%);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 3.4rem;
+ padding: 0.2rem 0.4rem;
+ border: 0;
+ background: transparent;
+ color: mat.get-color-from-palette(rmm.$rmm-default-primary);
+ font: inherit;
+ font-size: 0.84rem;
+ font-weight: 700;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.password-toggle:focus-visible {
+ outline: 2px solid mat.get-color-from-palette(rmm.$rmm-default-primary);
+ outline-offset: 2px;
+ border-radius: 4px;
+}
+
+.field input:focus,
+.field select:focus {
+ outline: 0;
+ border-color: mat.get-color-from-palette(rmm.$rmm-default-primary);
+ box-shadow: 0 0 0 3px rgba(0, 104, 183, 0.12);
+}
+
+.field.is-invalid input,
+.field.is-invalid select,
+.checkbox-row.is-invalid input {
+ border-color: #d04848;
+ background: #fff7f7;
+}
+
+.field.is-invalid input:focus,
+.field.is-invalid select:focus,
+.checkbox-row.is-invalid input:focus {
+ border-color: #c23030;
+ box-shadow: 0 0 0 3px rgba(210, 72, 72, 0.14);
+}
+
+.strength-meter {
+ display: grid;
+ grid-template-columns: auto minmax(120px, 220px) auto;
+ align-items: center;
+ gap: 0.8rem;
+ margin-top: 0.9rem;
+}
+
+.strength-meter span,
+.strength-meter strong {
+ white-space: nowrap;
+}
+
+.strength-meter progress {
+ width: 100%;
+ height: 10px;
+}
+
+.captcha-box {
+ padding: 1rem;
+ border-radius: 8px;
+ background: #f6fafd;
+ border: 1px solid #d6e2ec;
+ overflow-x: auto;
+}
+
+.captcha-host {
+ min-height: 78px;
+}
+
+.captcha-host.is-hidden {
+ display: none;
+}
+
+.captcha-host:empty::before {
+ content: 'Loading CAPTCHA...';
+ display: inline-block;
+ color: #587085;
+ font-size: 0.92rem;
+}
+
+.captcha-missing {
+ margin: 0;
+ padding: 0.8rem 0.9rem;
+ border: 1px solid #d6b087;
+ background: #fff3e4;
+ border-radius: 8px;
+ line-height: 1.5;
+}
+
+.policy-block {
+ background: linear-gradient(180deg, #f8fbfe 0%, #f2f7fb 100%);
+ padding: 1.25rem;
+ border: 1px solid #dce6ef;
+ border-radius: 8px;
+}
+
+.policy-block .section-head {
+ margin-bottom: 1rem;
+}
+
+.news-choice {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.9rem 1.2rem;
+}
+
+.news-choice > span {
+ font-weight: 700;
+}
+
+.inline-options {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+
+.inline-options label,
+.checkbox-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.65rem;
+}
+
+.inline-options label {
+ font-weight: 600;
+}
+
+.inline-options input,
+.checkbox-row input {
+ margin: 0.15rem 0 0;
+ flex: 0 0 auto;
+}
+
+.checkbox-row {
+ margin-top: 1rem;
+ line-height: 1.55;
+}
+
+.form-error {
+ margin: 1.1rem 0 0;
+ padding: 0.85rem 1rem;
+ border-radius: 8px;
+ border: 1px solid #d79a9a;
+ background: #fff2f2;
+ color: #8b1d1d;
+ line-height: 1.5;
+ font-weight: 600;
+}
+
+.field-error {
+ display: block;
+ color: #a12020;
+ font-size: 0.82rem;
+ line-height: 1.4;
+}
+
+.captcha-error {
+ margin-top: 0.75rem;
+}
+
+.form-actions {
+ display: flex;
+ align-items: center;
+ margin-top: 1.5rem;
+}
+
+.submit {
+ border: 0;
+ border-radius: 4px;
+ min-width: 132px;
+ min-height: 42px;
+ background: mat.get-color-from-palette(rmm.$rmm-default-primary);
+ color: #fff;
+ padding: 0 1.7rem;
+ font: inherit;
+ font-weight: 700;
+ font-size: 1rem;
+ cursor: pointer;
+ line-height: 36px;
+ box-shadow:
+ 0 3px 1px -2px rgba(0, 0, 0, 0.2),
+ 0 2px 2px 0 rgba(0, 0, 0, 0.14),
+ 0 1px 5px 0 rgba(0, 0, 0, 0.12);
+ transition: background-color 120ms ease, box-shadow 120ms ease;
+}
+
+.submit:hover {
+ background: mat.get-color-from-palette(rmm.$rmm-default-foreground);
+ box-shadow:
+ 0 4px 5px -2px rgba(0, 0, 0, 0.2),
+ 0 7px 10px 1px rgba(0, 0, 0, 0.14),
+ 0 2px 16px 1px rgba(0, 0, 0, 0.12);
+}
+
+.submit.is-submitting {
+ opacity: 0.84;
+ cursor: wait;
+}
+
+.honeypot {
+ position: absolute;
+ left: -10000px;
+ top: auto;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+}
+
+.signup-footer {
+ padding: 1.2rem 1.25rem 1.8rem;
+ background-image: linear-gradient(170deg, #014f89, #001e35);
+ color: rgba(255, 255, 255, 0.82);
+ text-align: center;
+}
+
+.footer-links {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+ gap: 1rem 1.2rem;
+ margin-bottom: 0.75rem;
+}
+
+.footer-links a {
+ color: rgba(255, 255, 255, 0.86);
+ text-decoration: none;
+}
+
+.signup-footer p {
+ margin: 0;
+ font-size: 0.92rem;
+}
+
+@media (max-width: 1100px) {
+ .signup-main {
+ grid-template-columns: 1fr;
+ }
+
+ .hero-panel {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+@media (max-width: 900px) {
+ .signup-header {
+ padding: 0.9rem 1.15rem;
+ }
+
+ .header-tagline {
+ font-size: 0.94rem;
+ }
+
+ .signup-main {
+ width: min(100% - 1.5rem, 1380px);
+ gap: 1.1rem;
+ }
+
+ .hero-copy,
+ .note,
+ .signup-form,
+ .form-heading {
+ padding-left: 1.2rem;
+ padding-right: 1.2rem;
+ }
+
+ .form-heading {
+ grid-template-columns: 1fr;
+ }
+
+ .choice-grid,
+ .field-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .strength-meter {
+ grid-template-columns: 1fr;
+ justify-items: start;
+ }
+
+ .captcha-box {
+ padding: 0.9rem;
+ }
+}
+
+@media (max-width: 720px) {
+ .signup-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.55rem;
+ }
+
+ .signup-main {
+ width: min(100% - 1rem, 1380px);
+ padding-top: 0.75rem;
+ padding-bottom: 1.2rem;
+ gap: 1rem;
+ }
+
+ .hero-panel {
+ grid-template-columns: 1fr;
+ }
+
+ .hero-copy,
+ .note,
+ .signup-form,
+ .form-heading {
+ padding-left: 1rem;
+ padding-right: 1rem;
+ }
+
+ .news-choice {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .form-actions {
+ align-items: stretch;
+ }
+
+ .submit {
+ width: 100%;
+ min-height: 46px;
+ }
+
+ .info-popover {
+ width: 16rem;
+ max-width: min(16rem, calc(100vw - 3rem));
+ }
+
+ .info-popover::before {
+ left: 0.9rem;
+ }
+
+ .info-trigger-end .info-popover::before {
+ left: auto;
+ right: 0.9rem;
+ }
+}
+
+@media (max-width: 560px) {
+ .signup-header {
+ padding: 0.85rem 1rem 0.9rem;
+ }
+
+ .brand img {
+ height: 28px;
+ }
+
+ .header-tagline {
+ font-size: 0.88rem;
+ line-height: 1.4;
+ }
+
+ .signup-footer p,
+ .footer-links a {
+ font-size: 0.9rem;
+ }
+
+ .hero-copy h1 {
+ font-size: clamp(1.8rem, 9vw, 2.4rem);
+ line-height: 1.08;
+ }
+
+ .hero-text,
+ .hero-subtext,
+ .note p,
+ .section-head p {
+ font-size: 0.95rem;
+ }
+
+ .signup-form,
+ .form-heading,
+ .hero-copy,
+ .note {
+ padding-left: 0.9rem;
+ padding-right: 0.9rem;
+ }
+
+ .field input,
+ .field select {
+ padding: 0.72rem 0.82rem;
+ font-size: 16px;
+ }
+
+ .policy-block {
+ padding: 1rem;
+ }
+
+ .inline-options {
+ gap: 0.8rem;
+ }
+
+ .captcha-box {
+ padding: 0.75rem;
+ }
+
+ .submit {
+ padding: 0 1.2rem;
+ }
+
+ .signup-footer {
+ padding: 1rem 0.9rem 1.4rem;
+ }
+}
+
+@media (max-width: 420px) {
+ .signup-main {
+ width: calc(100% - 0.75rem);
+ gap: 0.85rem;
+ }
+
+ .hero-copy h1 {
+ font-size: 1.85rem;
+ }
+
+ .hero-text,
+ .hero-subtext,
+ .note p,
+ .form-heading p,
+ .section-head p {
+ font-size: 0.92rem;
+ }
+
+ .form-heading {
+ padding-top: 1.15rem;
+ padding-bottom: 1.15rem;
+ }
+
+ .signup-form {
+ padding-top: 1.1rem;
+ padding-bottom: 1.25rem;
+ }
+
+ .note {
+ padding-top: 1rem;
+ padding-bottom: 1rem;
+ }
+
+ .policy-block {
+ padding: 1rem;
+ }
+
+ .footer-links {
+ gap: 0.75rem 1rem;
+ }
+}
diff --git a/src/app/signup/signup.component.spec.ts b/src/app/signup/signup.component.spec.ts
new file mode 100644
index 000000000..e4ef27e60
--- /dev/null
+++ b/src/app/signup/signup.component.spec.ts
@@ -0,0 +1,237 @@
+// --------- BEGIN RUNBOX LICENSE ---------
+// Copyright (C) 2016-2026 Runbox Solutions AS (runbox.com).
+//
+// This file is part of Runbox 7.
+//
+// Runbox 7 is free software: You can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the
+// Free Software Foundation, either version 3 of the License, or (at your
+// option) any later version.
+//
+// Runbox 7 is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+// General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Runbox 7. If not, see .
+// ---------- END RUNBOX LICENSE ----------
+
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule, NgForm, NgModel } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+import { ActivatedRoute, convertToParamMap } from '@angular/router';
+import { BehaviorSubject } from 'rxjs';
+import { SignupComponent } from './signup.component';
+
+describe('SignupComponent', () => {
+ let component: SignupComponent;
+ let fixture: ComponentFixture;
+ let httpMock: HttpTestingController;
+ let queryParamMap$: BehaviorSubject>;
+
+ beforeEach(async () => {
+ queryParamMap$ = new BehaviorSubject(convertToParamMap({ runbox7: '1' }));
+
+ await TestBed.configureTestingModule({
+ imports: [FormsModule, HttpClientTestingModule],
+ declarations: [SignupComponent],
+ providers: [
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ queryParamMap: queryParamMap$.asObservable(),
+ },
+ },
+ ],
+ }).compileComponents();
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(SignupComponent);
+ component = fixture.componentInstance;
+ httpMock = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpMock.verify();
+ });
+
+ function stubCaptchaInitialization(loadResult = false): jasmine.Spy {
+ return spyOn(component, 'loadHCaptchaScript').and.resolveTo(loadResult);
+ }
+
+ function flushLegacyMetadata(html?: string): void {
+ const request = httpMock.expectOne('/signup?legacy=1&runbox7=1');
+ expect(request.request.method).toBe('GET');
+ request.flush(html || `
+
+
+
+
+
+ `);
+ }
+
+ async function initComponent(html?: string, loadResult = false): Promise {
+ stubCaptchaInitialization(loadResult);
+ fixture.detectChanges();
+ flushLegacyMetadata(html);
+ await fixture.whenStable();
+ fixture.detectChanges();
+ }
+
+ function getForm(): NgForm {
+ return fixture.debugElement.query(By.css('form')).injector.get(NgForm);
+ }
+
+ function setInputValue(selector: string, value: string): HTMLInputElement {
+ const input = fixture.nativeElement.querySelector(selector) as HTMLInputElement;
+ input.value = value;
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ return input;
+ }
+
+ function setCheckboxValue(selector: string, checked: boolean): HTMLInputElement {
+ const input = fixture.nativeElement.querySelector(selector) as HTMLInputElement;
+ input.checked = checked;
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ return input;
+ }
+
+ async function fillRequiredFields(): Promise {
+ setInputValue('input[name="first_name"]', 'Joe');
+ setInputValue('input[name="last_name"]', 'Bond');
+ setInputValue('input[name="user"]', 'joebond');
+ setInputValue('input[name="password"]', 'S3cret!Pass');
+ setInputValue('input[name="email_alternative"]', 'joe@example.com');
+ setCheckboxValue('input[name="tos_accepted"]', true);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+ }
+
+ it('loads signup metadata from the legacy signup page', async () => {
+ await initComponent();
+
+ expect(component.signupAction).toBe('/mail/signup');
+ expect(component.hCaptchaSiteKey).toBe('test-site-key');
+ expect(component.runboxDomains).toEqual(['runbox.com', 'runbox.no', 'rbx.email']);
+ expect(component.runboxDomain).toBe('runbox.com');
+ });
+
+ it('applies query parameters during initialization', async () => {
+ queryParamMap$.next(convertToParamMap({
+ accountType: 'business',
+ domainType: 'user',
+ account_number: '12345',
+ runbox7: '7',
+ }));
+
+ await initComponent();
+
+ expect(component.accountType).toBe('business');
+ expect(component.domainType).toBe('user');
+ expect(component.accountNumber).toBe('12345');
+ expect(component.runbox7).toBe('7');
+ });
+
+ it('keeps safe defaults if legacy metadata cannot be fetched', async () => {
+ stubCaptchaInitialization(false);
+ fixture.detectChanges();
+
+ const request = httpMock.expectOne('/signup?legacy=1&runbox7=1');
+ request.flush('backend unavailable', { status: 500, statusText: 'Server Error' });
+
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ expect(component.signupAction).toBe('/signup');
+ expect(component.runboxDomains).toEqual(['runbox.com', 'runbox.no']);
+ expect(component.hCaptchaSiteKey).toBe('');
+ expect(component.hCaptchaError).toContain('CAPTCHA is temporarily unavailable');
+ });
+
+ it('shows field-level validation feedback after submit', async () => {
+ await initComponent();
+
+ const formElement = fixture.nativeElement.querySelector('form') as HTMLFormElement;
+ const focusSpy = spyOn(component, 'focusFirstInvalidField');
+
+ formElement.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ const fieldErrors = Array.from(fixture.nativeElement.querySelectorAll('.field-error'))
+ .map((el: HTMLElement) => el.textContent?.trim());
+
+ expect(component.submitError).toBe('Complete the required fields before continuing.');
+ expect(focusSpy).toHaveBeenCalledWith(formElement);
+ expect(fieldErrors).toContain('Enter your first name.');
+ expect(fieldErrors).toContain('Enter your last name.');
+ expect(fieldErrors).toContain('Choose a username for your mailbox.');
+ expect(fieldErrors).toContain('Enter a password for your account.');
+ expect(fieldErrors).toContain('Enter an email address for recovery and account notices.');
+ expect(fieldErrors).toContain('You must accept the terms to create an account.');
+ });
+
+ it('shows a field-level validation error for an invalid custom domain', async () => {
+ await initComponent();
+
+ component.domainType = 'user';
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ const domainControl = fixture.debugElement.query(By.css('input[name="userdomain"]')).injector.get(NgModel);
+ setInputValue('input[name="userdomain"]', 'invalid domain');
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ expect(component.showUserDomainError(domainControl, getForm())).toBeTrue();
+ expect(fixture.nativeElement.textContent).toContain('Enter a valid domain such as example.com.');
+ });
+
+ it('blocks submit if captcha is missing even when required fields are valid', async () => {
+ await initComponent();
+ await fillRequiredFields();
+
+ const formElement = fixture.nativeElement.querySelector('form') as HTMLFormElement;
+ const submitSpy = spyOn(formElement, 'submit');
+
+ component.onSubmit(getForm(), formElement);
+ fixture.detectChanges();
+
+ expect(component.showCaptchaValidationError).toBeTrue();
+ expect(component.submitError).toBe('Complete the CAPTCHA verification before submitting.');
+ expect(submitSpy).not.toHaveBeenCalled();
+ });
+
+ it('submits the native form when validation and captcha both pass', async () => {
+ await initComponent();
+ await fillRequiredFields();
+
+ const formElement = fixture.nativeElement.querySelector('form') as HTMLFormElement;
+ const captchaResponse = document.createElement('textarea');
+ captchaResponse.name = 'h-captcha-response';
+ captchaResponse.value = 'captcha-token';
+ formElement.appendChild(captchaResponse);
+ const submitSpy = spyOn(formElement, 'submit');
+
+ component.onSubmit(getForm(), formElement);
+
+ expect(component.submitInProgress).toBeTrue();
+ expect(submitSpy).toHaveBeenCalled();
+ expect(component.submitError).toBe('');
+ expect(component.showCaptchaValidationError).toBeFalse();
+ });
+});
diff --git a/src/app/signup/signup.component.ts b/src/app/signup/signup.component.ts
new file mode 100644
index 000000000..8cbb75795
--- /dev/null
+++ b/src/app/signup/signup.component.ts
@@ -0,0 +1,377 @@
+// --------- BEGIN RUNBOX LICENSE ---------
+// Copyright (C) 2016-2026 Runbox Solutions AS (runbox.com).
+//
+// This file is part of Runbox 7.
+//
+// Runbox 7 is free software: You can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the
+// Free Software Foundation, either version 3 of the License, or (at your
+// option) any later version.
+//
+// Runbox 7 is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+// General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Runbox 7. If not, see .
+// ---------- END RUNBOX LICENSE ----------
+
+import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { NgForm, NgModel } from '@angular/forms';
+import { ActivatedRoute } from '@angular/router';
+import { environment } from '../../environments/environment';
+
+type AccountType = 'person' | 'business';
+type DomainType = 'runbox' | 'user';
+type SignupEnvironment = typeof environment & { SIGNUP_HCAPTCHA_SITE_KEY?: string };
+
+@Component({
+ selector: 'app-signup',
+ templateUrl: './signup.component.html',
+ styleUrls: ['./signup.component.scss'],
+})
+export class SignupComponent implements OnInit, AfterViewInit, OnDestroy {
+ @ViewChild('captchaContainer') captchaContainer?: ElementRef;
+
+ accountType: AccountType = 'person';
+ domainType: DomainType = 'runbox';
+
+ user = '';
+ userDomain = '';
+ runboxDomain = 'runbox.com';
+ firstName = '';
+ lastName = '';
+ company = '';
+ password = '';
+ showPassword = false;
+ emailAlternative = '';
+ phoneNumberCellular = '';
+ referrer = '';
+ sendNewsOffers = '';
+ tosAccepted = false;
+ passwordStrength = 0;
+ accountNumber = '';
+ runbox7 = '1';
+ timezone = 'UTC';
+ signupAction = '/signup';
+
+ runboxDomains = ['runbox.com', 'runbox.no'];
+ readonly referrers = [
+ 'Advertisement',
+ 'Friend or family',
+ 'News media',
+ 'Review website',
+ 'Search engine',
+ 'Social media',
+ 'Other',
+ ];
+
+ hCaptchaSiteKey = (environment as SignupEnvironment).SIGNUP_HCAPTCHA_SITE_KEY || '';
+ hCaptchaError = '';
+ submitError = '';
+ submitInProgress = false;
+ showCaptchaValidationError = false;
+
+ private hCaptchaWidgetId: string | null = null;
+ private hCaptchaReady = false;
+ private nativeSubmitting = false;
+ private pendingCaptchaRender = false;
+ private readonly customDomainPattern = /^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$/;
+
+ constructor(
+ private route: ActivatedRoute,
+ private http: HttpClient,
+ ) {}
+
+ ngOnInit(): void {
+ document.body.classList.add('signup-page');
+ document.getElementById('main')?.classList.add('signup-page-shell');
+
+ const host = window?.location?.hostname || '';
+ if (host.endsWith('.no')) {
+ this.runboxDomain = 'runbox.no';
+ }
+ const resolvedTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ if (resolvedTz) {
+ this.timezone = resolvedTz;
+ }
+ this.route.queryParamMap.subscribe((params) => {
+ const accountType = params.get('accountType');
+ if (accountType === 'business' || accountType === 'person') {
+ this.accountType = accountType;
+ }
+ const domainType = params.get('domainType');
+ if (domainType === 'user' || domainType === 'runbox') {
+ this.domainType = domainType;
+ }
+ this.accountNumber = params.get('account_number') || params.get('accountNumber') || '';
+ this.runbox7 = params.get('runbox7') || '1';
+ });
+
+ void this.initializeHCaptcha();
+ }
+
+ ngAfterViewInit(): void {
+ if (this.pendingCaptchaRender) {
+ this.renderHCaptcha();
+ }
+ }
+
+ ngOnDestroy(): void {
+ document.body.classList.remove('signup-page');
+ document.getElementById('main')?.classList.remove('signup-page-shell');
+ }
+
+ onPasswordChange(): void {
+ let score = 0;
+ if (this.password.length >= 8) {
+ score++;
+ }
+ if (/[a-z]/.test(this.password) && /[A-Z]/.test(this.password)) {
+ score++;
+ }
+ if (/\d/.test(this.password)) {
+ score++;
+ }
+ if (/[^A-Za-z0-9]/.test(this.password)) {
+ score++;
+ }
+ this.passwordStrength = score;
+ }
+
+ onUserDomainBlur(): void {
+ this.userDomain = this.userDomain.trim().toLowerCase();
+ }
+
+ onSubmit(form: NgForm, formElement: HTMLFormElement): void {
+ this.submitError = '';
+ this.showCaptchaValidationError = false;
+
+ if (this.nativeSubmitting) {
+ return;
+ }
+
+ if (!form.valid) {
+ this.submitError = 'Complete the required fields before continuing.';
+ this.focusFirstInvalidField(formElement);
+ return;
+ }
+
+ if (this.domainType === 'user' && !this.isUserDomainValid()) {
+ this.submitError = 'Enter a valid domain such as example.com.';
+ this.focusFirstInvalidField(formElement);
+ return;
+ }
+
+ if (this.passwordStrength < 3) {
+ this.submitError = 'Choose a stronger password before continuing.';
+ const passwordInput = formElement.querySelector('input[name="password"]');
+ passwordInput?.focus();
+ return;
+ }
+
+ if (!this.hCaptchaSiteKey) {
+ this.submitError = 'CAPTCHA is unavailable right now. Use the legacy signup page or try again shortly.';
+ return;
+ }
+
+ if (!this.hasCaptchaResponse(formElement)) {
+ this.showCaptchaValidationError = true;
+ this.submitError = 'Complete the CAPTCHA verification before submitting.';
+ this.captchaContainer?.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ return;
+ }
+
+ this.submitInProgress = true;
+ this.nativeSubmitting = true;
+ formElement.submit();
+ }
+
+ showFieldError(control?: NgModel | null, form?: NgForm): boolean {
+ if (!control) {
+ return false;
+ }
+
+ return control.invalid && (control.touched || control.dirty || Boolean(form?.submitted));
+ }
+
+ showUserDomainError(control?: NgModel | null, form?: NgForm): boolean {
+ if (!control) {
+ return false;
+ }
+
+ const shouldShow = control.touched || control.dirty || Boolean(form?.submitted);
+ return shouldShow && (!this.userDomain.trim() || !this.isUserDomainValid());
+ }
+
+ showPasswordError(control?: NgModel | null, form?: NgForm): boolean {
+ if (!control) {
+ return false;
+ }
+
+ const shouldShow = control.touched || control.dirty || Boolean(form?.submitted);
+ return shouldShow && (control.invalid || this.passwordStrength < 3);
+ }
+
+ passwordErrorMessage(control?: NgModel | null): string {
+ if (control?.errors?.['required']) {
+ return 'Enter a password for your account.';
+ }
+
+ return 'Use a stronger password with a strength of at least 3/4.';
+ }
+
+ userDomainErrorMessage(control?: NgModel | null): string {
+ if (!this.userDomain.trim() || control?.errors?.['required']) {
+ return 'Enter your domain name.';
+ }
+
+ return 'Enter a valid domain such as example.com.';
+ }
+
+ private isUserDomainValid(): boolean {
+ return this.customDomainPattern.test(this.userDomain.trim());
+ }
+
+ private async initializeHCaptcha(): Promise {
+ await this.loadLegacySignupMetadata();
+
+ if (!this.hCaptchaSiteKey) {
+ this.hCaptchaError = 'CAPTCHA is temporarily unavailable. Please use the legacy signup page below.';
+ return;
+ }
+
+ const captchaLoaded = await this.loadHCaptchaScript();
+ if (!captchaLoaded) {
+ return;
+ }
+
+ this.hCaptchaReady = true;
+ this.renderHCaptcha();
+ }
+
+ private loadLegacySignupMetadata(): Promise {
+ return new Promise((resolve) => {
+ this.http
+ .get('/signup?legacy=1&runbox7=1', { responseType: 'text' })
+ .subscribe({
+ next: (html) => {
+ const doc = new DOMParser().parseFromString(html, 'text/html');
+ const legacyWidget = doc.querySelector('.h-captcha');
+ const legacyForm = doc.querySelector('form[name="signup"], form[action*="signup"]');
+ const legacyDomains = Array.from(
+ doc.querySelectorAll('select[name="runboxDomain"] option'),
+ )
+ .map((option) => option.value.trim())
+ .filter((domain, index, domains) => Boolean(domain) && domains.indexOf(domain) === index);
+
+ this.hCaptchaSiteKey = legacyWidget?.getAttribute('data-sitekey') || this.hCaptchaSiteKey;
+ this.signupAction = legacyForm?.getAttribute('action') || this.signupAction;
+ if (legacyDomains.length > 0) {
+ this.runboxDomains = legacyDomains;
+ if (!this.runboxDomains.includes(this.runboxDomain)) {
+ this.runboxDomain = this.runboxDomains[0];
+ }
+ }
+ resolve();
+ },
+ error: () => resolve(),
+ });
+ });
+ }
+
+ private loadHCaptchaScript(): Promise {
+ return new Promise((resolve, reject) => {
+ const existingScript = document.querySelector('script[data-runbox-hcaptcha="1"]');
+ if (existingScript) {
+ if ((window as WindowWithHCaptcha).hcaptcha) {
+ resolve(true);
+ return;
+ }
+
+ const pollForHCaptcha = window.setInterval(() => {
+ if ((window as WindowWithHCaptcha).hcaptcha) {
+ window.clearInterval(pollForHCaptcha);
+ resolve(true);
+ }
+ }, 100);
+
+ existingScript.addEventListener('load', () => {
+ window.clearInterval(pollForHCaptcha);
+ resolve(true);
+ }, { once: true });
+ existingScript.addEventListener('error', () => reject(new Error('Failed to load hCaptcha.')), { once: true });
+ return;
+ }
+
+ const script = document.createElement('script');
+ script.src = 'https://hcaptcha.com/1/api.js?render=explicit';
+ script.async = true;
+ script.defer = true;
+ script.setAttribute('data-runbox-hcaptcha', '1');
+ script.addEventListener('load', () => resolve(true), { once: true });
+ script.addEventListener('error', () => reject(new Error('Failed to load hCaptcha.')), { once: true });
+ document.body.appendChild(script);
+ }).catch((): boolean => {
+ this.hCaptchaError = 'CAPTCHA could not be loaded. Please use the legacy signup page below.';
+ return false;
+ });
+ }
+
+ private renderHCaptcha(): void {
+ if (!this.hCaptchaReady || !this.hCaptchaSiteKey) {
+ return;
+ }
+
+ const container = this.captchaContainer?.nativeElement;
+ const hcaptcha = (window as WindowWithHCaptcha).hcaptcha;
+ if (!container || !hcaptcha) {
+ this.pendingCaptchaRender = true;
+ window.setTimeout(() => this.renderHCaptcha(), 0);
+ return;
+ }
+
+ this.pendingCaptchaRender = false;
+
+ if (this.hCaptchaWidgetId !== null) {
+ return;
+ }
+
+ this.hCaptchaWidgetId = hcaptcha.render(container, {
+ sitekey: this.hCaptchaSiteKey,
+ callback: () => {
+ this.hCaptchaError = '';
+ this.submitError = '';
+ },
+ 'expired-callback': () => {
+ this.hCaptchaError = 'CAPTCHA expired. Complete it again before submitting.';
+ },
+ 'error-callback': () => {
+ this.hCaptchaError = 'CAPTCHA failed to load correctly. Try again or use the legacy signup page.';
+ },
+ });
+ }
+
+ private hasCaptchaResponse(formElement: HTMLFormElement): boolean {
+ const response = formElement.querySelector('textarea[name="h-captcha-response"], input[name="h-captcha-response"]');
+ return Boolean(response?.value?.trim());
+ }
+
+ private focusFirstInvalidField(formElement: HTMLFormElement): void {
+ const firstInvalidField = formElement.querySelector(
+ 'input.ng-invalid, select.ng-invalid, textarea.ng-invalid, input:invalid, select:invalid, textarea:invalid',
+ );
+ firstInvalidField?.focus();
+ firstInvalidField?.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ }
+}
+
+interface HCaptchaApi {
+ render(container: string | HTMLElement, params: Record): string;
+}
+
+interface WindowWithHCaptcha extends Window {
+ hcaptcha?: HCaptchaApi;
+}
diff --git a/src/app/signup/signup.module.ts b/src/app/signup/signup.module.ts
new file mode 100644
index 000000000..925d2b950
--- /dev/null
+++ b/src/app/signup/signup.module.ts
@@ -0,0 +1,36 @@
+// --------- BEGIN RUNBOX LICENSE ---------
+// Copyright (C) 2016-2026 Runbox Solutions AS (runbox.com).
+//
+// This file is part of Runbox 7.
+//
+// Runbox 7 is free software: You can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the
+// Free Software Foundation, either version 3 of the License, or (at your
+// option) any later version.
+//
+// Runbox 7 is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+// General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Runbox 7. If not, see .
+// ---------- END RUNBOX LICENSE ----------
+
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { NgModule } from '@angular/core';
+import { RouterModule } from '@angular/router';
+import { SignupComponent } from './signup.component';
+
+@NgModule({
+ imports: [
+ CommonModule,
+ FormsModule,
+ RouterModule.forChild([
+ { path: '', component: SignupComponent },
+ ]),
+ ],
+ declarations: [SignupComponent],
+})
+export class SignupModule {}
diff --git a/src/app/start/startdesk.component.scss b/src/app/start/startdesk.component.scss
index fa1e78299..10716c024 100644
--- a/src/app/start/startdesk.component.scss
+++ b/src/app/start/startdesk.component.scss
@@ -1,5 +1,5 @@
-@import '../../../node_modules/@angular/material/theming';
-$rmm-default-highlight: mat-palette($mat-light-blue, 100, 50, 200);
+@use '@angular/material' as mat;
+$rmm-default-highlight: mat.define-palette(mat.$light-blue-palette, 100, 50, 200);
#startdesk {
position: absolute;
diff --git a/src/app/websocketsearch/websocketsearch.service.spec.ts b/src/app/websocketsearch/websocketsearch.service.spec.ts
index 4d718eaa3..00d1188b9 100644
--- a/src/app/websocketsearch/websocketsearch.service.spec.ts
+++ b/src/app/websocketsearch/websocketsearch.service.spec.ts
@@ -262,6 +262,7 @@ describe('WebSocketSearchService', () => {
describe('Error handling', () => {
it('should handle WebSocket errors', (done) => {
+ const consoleLogSpy = spyOn(console, 'log');
service.open();
mockWebSocket.simulateOpen();
@@ -271,6 +272,7 @@ describe('WebSocketSearchService', () => {
mockWebSocket.simulateError(new Error('WebSocket error'));
setTimeout(() => {
+ expect(consoleLogSpy).toHaveBeenCalledWith('websocket error', jasmine.any(Error));
expect(service.searchInProgress).toBe(false);
done();
}, 10);
diff --git a/src/app/welcome/welcomedesk.component.spec.ts b/src/app/welcome/welcomedesk.component.spec.ts
index c99110caa..d2c2eca62 100644
--- a/src/app/welcome/welcomedesk.component.spec.ts
+++ b/src/app/welcome/welcomedesk.component.spec.ts
@@ -18,9 +18,13 @@
// ---------- END RUNBOX LICENSE ----------
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { MatIconModule } from '@angular/material/icon';
+import { MatIconTestingModule } from '@angular/material/icon/testing';
+import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
import { RunboxWebmailAPI } from '../rmmapi/rbwebmail';
import { WelcomeDeskComponent } from './welcomedesk.component';
-import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
+import { ActivatedRoute, convertToParamMap } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
import { of } from 'rxjs';
describe('WelcomeDeskComponent', () => {
@@ -31,6 +35,10 @@ describe('WelcomeDeskComponent', () => {
await TestBed.configureTestingModule({
declarations: [ WelcomeDeskComponent ],
imports: [
+ MatCardModule,
+ MatIconModule,
+ MatIconTestingModule,
+ RouterTestingModule,
],
providers:
[
diff --git a/src/build/gen-env.js b/src/build/gen-env.js
index 8db3a977b..ffc3ceeea 100644
--- a/src/build/gen-env.js
+++ b/src/build/gen-env.js
@@ -13,6 +13,7 @@ process.env.BUILD_TIMESTAMP ??= new Date().toJSON()
const env = [
['BUILD_TIMESTAMP', assertString],
['SENTRY_DSN', tryCatch(assertString, orNull)],
+ ['SIGNUP_HCAPTCHA_SITE_KEY', tryCatch(assertString, orEmptyString)],
]
function assertString(input) {
@@ -37,6 +38,10 @@ function orNull () {
return null
}
+function orEmptyString () {
+ return ''
+}
+
fs.writeFileSync('src/environments/env.ts', `
/* eslint-disable @typescript-eslint/quotes */
diff --git a/src/styles.scss b/src/styles.scss
index ed7a85188..3a797937c 100644
--- a/src/styles.scss
+++ b/src/styles.scss
@@ -2,6 +2,7 @@
@import '../iconfont/material-icons.css';
@import "../node_modules/angular-calendar/css/angular-calendar.css";
@import "@danielmoncada/angular-datetime-picker/assets/style/picker.min.css";
+@import './styles/rmm-theme-vars';
// Include non-theme styles for core.
// TODO(v15): As of v15 mat.legacy-core no longer includes default typography styles.
@@ -15,31 +16,6 @@
@include mat.all-legacy-component-typographies();
@include mat.legacy-core();
-// GTA 13.05.2017: From https://material.angular.io/guide/theming and https://www.materialui.co/colors:
-
-$rmm-default-primary: mat.define-palette(mat.$light-blue-palette, 900, A400, A700);
-$rmm-default-accent: mat.define-palette(mat.$blue-palette, 200, 100, 400);
-$rmm-default-warn: mat.define-palette(mat.$orange-palette, 500, A400, A700);
-$rmm-default-caution: mat.define-palette(mat.$deep-orange-palette, 500, A400, A700);
-$rmm-default-error: mat.define-palette(mat.$red-palette, 500, A400, A700);
-$rmm-default-foreground: mat.define-palette(mat.$light-blue-palette, 400, 200, 600);
-$rmm-default-highlight: mat.define-palette(mat.$blue-palette, 100, 50, 200);
-$rmm-default-background: mat.define-palette(mat.$light-blue-palette, 900, A400, A700);
-
-$rmm-dark-background: #011b40;
-$rmm-dark-background-03: rgba(1,27,64,0.3);
-$rmm-darker-background: #01001c;
-$rmm-gray: #dddddd;
-$rmm-gray-light: #eeeeee;
-$rmm-gray-lighter: #f3f3f3;
-
-$rmm-default-theme: mat.define-light-theme($rmm-default-primary, $rmm-default-accent, $rmm-default-warn);
-
-$rmm-default-lighter-gray: #eeeeee;
-$rmm-default-light-gray: #f4f4f4;
-$rmm-default-dark-gray: #949494;
-$rmm-default-black: #444444;
-
@include mat.all-legacy-component-themes($rmm-default-theme);
// GTA 13.06.2018: Load custom fonts
@@ -404,6 +380,28 @@ mat-grid-tile.tableTitle {
justify-content: center;
}
+body.signup-page {
+ height: auto;
+ min-height: 100%;
+ overflow-y: auto;
+ overscroll-behavior: auto;
+}
+
+#main.signup-page-shell {
+ position: static;
+ width: 100%;
+ height: auto;
+ min-height: 100vh;
+ overflow: visible;
+ display: block;
+}
+
+body.signup-page app-rmm {
+ display: block !important;
+ width: 100% !important;
+ min-height: 100vh;
+}
+
/* Snackbar */
.mat-snack-bar-container {
diff --git a/src/styles/_rmm-theme-vars.scss b/src/styles/_rmm-theme-vars.scss
new file mode 100644
index 000000000..37f0ac6c5
--- /dev/null
+++ b/src/styles/_rmm-theme-vars.scss
@@ -0,0 +1,43 @@
+/* --------- BEGIN RUNBOX LICENSE ---------
+Copyright (C) 2016-2026 Runbox Solutions AS (runbox.com).
+
+This file is part of Runbox 7.
+
+Runbox 7 is free software: You can redistribute it and/or modify it
+under the terms of the GNU General Public License as published by the
+Free Software Foundation, either version 3 of the License, or (at your
+option) any later version.
+
+Runbox 7 is distributed in the hope that it will be useful, but
+WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with Runbox 7. If not, see .
+---------- END RUNBOX LICENSE ---------- */
+
+@use '@angular/material' as mat;
+
+$rmm-default-primary: mat.define-palette(mat.$light-blue-palette, 900, A400, A700);
+$rmm-default-accent: mat.define-palette(mat.$blue-palette, 200, 100, 400);
+$rmm-default-warn: mat.define-palette(mat.$orange-palette, 500, A400, A700);
+$rmm-default-caution: mat.define-palette(mat.$deep-orange-palette, 500, A400, A700);
+$rmm-default-error: mat.define-palette(mat.$red-palette, 500, A400, A700);
+$rmm-default-foreground: mat.define-palette(mat.$light-blue-palette, 400, 200, 600);
+$rmm-default-highlight: mat.define-palette(mat.$blue-palette, 100, 50, 200);
+$rmm-default-background: mat.define-palette(mat.$light-blue-palette, 900, A400, A700);
+
+$rmm-dark-background: #011b40;
+$rmm-dark-background-03: rgba(1,27,64,0.3);
+$rmm-darker-background: #01001c;
+$rmm-gray: #dddddd;
+$rmm-gray-light: #eeeeee;
+$rmm-gray-lighter: #f3f3f3;
+
+$rmm-default-theme: mat.define-light-theme($rmm-default-primary, $rmm-default-accent, $rmm-default-warn);
+
+$rmm-default-lighter-gray: #eeeeee;
+$rmm-default-light-gray: #f4f4f4;
+$rmm-default-dark-gray: #949494;
+$rmm-default-black: #444444;