Skip to content

Commit d94f75a

Browse files
authored
Merge pull request #81 from Domar95/27-integrate-contact-form-with-email-service
27 integrate contact form with email service
2 parents c0b4d14 + 72dc695 commit d94f75a

14 files changed

+402
-15
lines changed

firebase.json

+14-1
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,18 @@
99
"frameworksBackend": {
1010
"region": "europe-west1"
1111
}
12-
}
12+
},
13+
"functions": [
14+
{
15+
"source": "functions",
16+
"codebase": "default",
17+
"ignore": [
18+
"venv",
19+
".git",
20+
"firebase-debug.log",
21+
"firebase-debug.*.log",
22+
"*.local"
23+
]
24+
}
25+
]
1326
}

functions/.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*.local
2+
/venv

functions/main.py

+128
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import os
2+
import json
3+
import base64
4+
5+
from firebase_functions import https_fn
6+
from firebase_admin import initialize_app, app_check
7+
from google.oauth2.credentials import Credentials
8+
from googleapiclient.discovery import build
9+
from email.mime.text import MIMEText
10+
11+
initialize_app()
12+
13+
REQUIRED_SECRETS = [
14+
"GMAIL_REFRESH_TOKEN",
15+
"GMAIL_CLIENT_ID",
16+
"GMAIL_CLIENT_SECRET",
17+
"SENDER_EMAIL_ADDRESS",
18+
]
19+
20+
ALLOWED_ORIGIN = "https://md-led-visual.web.app"
21+
22+
cors_headers = {
23+
"Access-Control-Allow-Origin": ALLOWED_ORIGIN,
24+
"Access-Control-Allow-Methods": "POST, OPTIONS",
25+
"Access-Control-Allow-Headers": "Content-Type, X-Firebase-AppCheck",
26+
"Content-Type": "application/json",
27+
}
28+
29+
30+
def get_gmail_service():
31+
"""Authenticate with Gmail API"""
32+
creds = Credentials(
33+
token=None,
34+
refresh_token=os.environ.get("GMAIL_REFRESH_TOKEN"),
35+
client_id=os.environ.get("GMAIL_CLIENT_ID"),
36+
client_secret=os.environ.get("GMAIL_CLIENT_SECRET"),
37+
token_uri="https://oauth2.googleapis.com/token",
38+
)
39+
return build("gmail", "v1", credentials=creds)
40+
41+
42+
def create_email_message(receiver_email: str, subject: str, message_text: str):
43+
"""Create and encode an email message"""
44+
message = MIMEText(message_text)
45+
message["to"] = receiver_email
46+
message["from"] = os.environ.get("SENDER_EMAIL_ADDRESS")
47+
message["subject"] = subject
48+
return base64.urlsafe_b64encode(message.as_bytes()).decode("utf-8")
49+
50+
51+
@https_fn.on_request(secrets=REQUIRED_SECRETS, enforce_app_check=True)
52+
def send_email(req: https_fn.Request) -> https_fn.Response:
53+
"""Handles email sending via Gmail API"""
54+
55+
# Handle Preflight (OPTIONS) Requests
56+
if req.method == "OPTIONS":
57+
return https_fn.Response(status=204, headers=cors_headers)
58+
59+
# Restrict API access based on Origin
60+
origin = req.headers.get("Origin")
61+
if origin != ALLOWED_ORIGIN:
62+
return https_fn.Response(
63+
json.dumps({"success": False, "error": "Forbidden - Invalid Origin"}),
64+
status=403,
65+
headers=cors_headers,
66+
)
67+
68+
# Get app check token
69+
app_check_token = req.headers.get("X-Firebase-AppCheck")
70+
if not app_check_token:
71+
return https_fn.Response(
72+
json.dumps(
73+
{"success": False, "error": "Unauthorized - No App Check Token"}
74+
),
75+
status=401,
76+
headers=cors_headers,
77+
)
78+
79+
# Verify app check token
80+
try:
81+
app_check.verify_token(app_check_token) # Throws error if invalid
82+
except Exception:
83+
return https_fn.Response(
84+
json.dumps(
85+
{"success": False, "error": "Unauthorized - Invalid App Check Token"}
86+
),
87+
status=403,
88+
headers=cors_headers,
89+
)
90+
91+
try:
92+
# Parse request data
93+
data = req.get_json()
94+
receiver_email = data.get("email")
95+
subject = data.get("subject")
96+
message_text = data.get("message")
97+
98+
if not receiver_email or not subject or not message_text:
99+
return https_fn.Response(
100+
json.dumps({"success": False, "error": "Missing required fields"}),
101+
status=400,
102+
headers=cors_headers,
103+
)
104+
105+
# Create and send the email
106+
raw_message = create_email_message(receiver_email, subject, message_text)
107+
service = get_gmail_service()
108+
send_request = (
109+
service.users()
110+
.messages()
111+
.send(userId="me", body={"raw": raw_message})
112+
.execute()
113+
)
114+
115+
return https_fn.Response(
116+
json.dumps(
117+
{"success": True, "message": "Email sent!", "id": send_request["id"]}
118+
),
119+
status=200,
120+
headers=cors_headers,
121+
)
122+
123+
except Exception as e:
124+
return https_fn.Response(
125+
json.dumps({"success": False, "error": str(e)}),
126+
status=500,
127+
headers=cors_headers,
128+
)

functions/requirements.txt

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
blinker==1.9.0
2+
CacheControl==0.14.2
3+
cachetools==5.5.1
4+
certifi==2025.1.31
5+
cffi==1.17.1
6+
charset-normalizer==3.4.1
7+
click==8.1.8
8+
cloudevents==1.9.0
9+
cryptography==44.0.1
10+
deprecation==2.1.0
11+
firebase-admin==6.6.0
12+
firebase-functions==0.1.2
13+
Flask==3.1.0
14+
Flask-Cors==5.0.0
15+
functions-framework==3.8.2
16+
google-api-core==2.24.1
17+
google-api-python-client==2.160.0
18+
google-auth==2.38.0
19+
google-auth-httplib2==0.2.0
20+
google-cloud-core==2.4.1
21+
google-cloud-firestore==2.20.0
22+
google-cloud-storage==3.0.0
23+
google-crc32c==1.6.0
24+
google-events==0.14.0
25+
google-resumable-media==2.7.2
26+
googleapis-common-protos==1.67.0rc1
27+
grpcio==1.70.0
28+
grpcio-status==1.70.0
29+
gunicorn==23.0.0
30+
httplib2==0.22.0
31+
idna==3.10
32+
itsdangerous==2.2.0
33+
Jinja2==3.1.5
34+
MarkupSafe==3.0.2
35+
msgpack==1.1.0
36+
packaging==24.2
37+
proto-plus==1.26.0
38+
protobuf==5.29.3
39+
pyasn1==0.6.1
40+
pyasn1_modules==0.4.1
41+
pycparser==2.22
42+
PyJWT==2.10.1
43+
pyparsing==3.2.1
44+
PyYAML==6.0.2
45+
requests==2.32.3
46+
rsa==4.9
47+
typing_extensions==4.12.2
48+
uritemplate==4.1.1
49+
urllib3==2.3.0
50+
watchdog==6.0.0
51+
Werkzeug==3.1.3

src/app/app.config.ts

+12-1
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,13 @@ import {
55
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
66
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
77
import { provideRouter, withInMemoryScrolling } from '@angular/router';
8-
import { initializeApp, provideFirebaseApp } from '@angular/fire/app';
8+
import { getApp, initializeApp, provideFirebaseApp } from '@angular/fire/app';
99
import { provideStorage, getStorage } from '@angular/fire/storage';
10+
import {
11+
initializeAppCheck,
12+
provideAppCheck,
13+
ReCaptchaV3Provider,
14+
} from '@angular/fire/app-check';
1015

1116
import { routes } from './app.routes';
1217
import { environment } from 'src/environments/environment';
@@ -25,5 +30,11 @@ export const appConfig: ApplicationConfig = {
2530
provideHttpClient(withInterceptorsFromDi()),
2631
provideFirebaseApp(() => initializeApp(environment.firebaseConfig)),
2732
provideStorage(() => getStorage()),
33+
provideAppCheck(() =>
34+
initializeAppCheck(getApp(), {
35+
provider: new ReCaptchaV3Provider(environment.recaptchaSiteKey),
36+
isTokenAutoRefreshEnabled: true,
37+
})
38+
),
2839
],
2940
};

src/components/contact/contact-form/contact-form.component.ts

+54-10
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,71 @@ import { MatFormFieldModule } from '@angular/material/form-field';
1010
import { MatIconModule } from '@angular/material/icon';
1111
import { MatInputModule } from '@angular/material/input';
1212

13+
import { EmailService } from '@services/email.service';
14+
import { NotificationService } from '@services/notification.service';
15+
1316
@Component({
1417
selector: 'mdlv-contact-form',
15-
imports: [ReactiveFormsModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatIconModule],
18+
imports: [
19+
ReactiveFormsModule,
20+
MatFormFieldModule,
21+
MatInputModule,
22+
MatButtonModule,
23+
MatIconModule,
24+
],
1625
templateUrl: './contact-form.component.html',
17-
styleUrl: './contact-form.component.scss'
26+
styleUrl: './contact-form.component.scss',
1827
})
1928
export class ContactFormComponent {
2029
@Input() isHandset!: boolean;
2130

2231
//TODO: Add privacy policy checkbox
2332
contactForm = new FormGroup({
24-
name: new FormControl('', Validators.required),
25-
surname: new FormControl('', Validators.required),
26-
email: new FormControl('', [Validators.required, Validators.email]),
27-
phone: new FormControl('', Validators.required),
28-
message: new FormControl('', Validators.required),
33+
name: new FormControl<string>('', {
34+
nonNullable: true,
35+
validators: Validators.required,
36+
}),
37+
surname: new FormControl<string>('', {
38+
nonNullable: true,
39+
validators: Validators.required,
40+
}),
41+
email: new FormControl<string>('', {
42+
nonNullable: true,
43+
validators: [Validators.required, Validators.email],
44+
}),
45+
phone: new FormControl<string>('', {
46+
nonNullable: true,
47+
validators: Validators.required,
48+
}),
49+
message: new FormControl<string>('', {
50+
nonNullable: true,
51+
validators: Validators.required,
52+
}),
2953
});
3054

31-
onSubmit() {
32-
console.warn(this.contactForm.value);
33-
// TODO: Implement solution to send email; prob use EmailJS
55+
constructor(
56+
private emailService: EmailService,
57+
private notificationService: NotificationService
58+
) {}
59+
60+
async onSubmit() {
61+
if (this.contactForm.invalid) {
62+
console.warn('Form is invalid, cannot submit.');
63+
return;
64+
}
65+
66+
const { name, surname, email, phone, message } =
67+
this.contactForm.getRawValue();
68+
69+
try {
70+
await this.emailService.send(name, surname, email, phone, message);
71+
this.notificationService.openSnackBar('Wiadomość została wysłana!');
72+
} catch (error) {
73+
this.notificationService.openSnackBar(
74+
'Nie udało się wysłać wiadomości. Spróbuj później.'
75+
);
76+
throw error;
77+
}
3478
}
3579

3680
get requiredErrorMessage() {
+2-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
export const environment = {
22
firebaseConfig: {
3-
apiKey: 'AIzaSyBdVToWbccXWBfWfA5iS4iEadugD-VNhNM',
3+
apiKey: 'AIzaSyDsAQWcFNcNGIwBSylnyHz8P8BZdKddn5g',
44
authDomain: 'md-led-visual.firebaseapp.com',
55
projectId: 'md-led-visual',
66
storageBucket: 'md-led-visual-dev',
77
messagingSenderId: '654372960384',
88
appId: '1:654372960384:web:198c257539512bf3470e65',
99
measurementId: 'G-EQMM08X1SE',
1010
},
11+
recaptchaSiteKey: '6Lc0LdgqAAAAAPkjqlF2O24bL7IVFJE7KQPxFcHl',
1112
};

src/environments/environment.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
export const environment = {
22
firebaseConfig: {
3-
apiKey: 'AIzaSyBdVToWbccXWBfWfA5iS4iEadugD-VNhNM',
3+
apiKey: 'AIzaSyDsAQWcFNcNGIwBSylnyHz8P8BZdKddn5g',
44
authDomain: 'md-led-visual.firebaseapp.com',
55
projectId: 'md-led-visual',
66
storageBucket: 'md-led-visual-dev', // TODO: temporarily use dev; update once perm/auth is set up and images are added to prod: md-led-visual.firebasestorage.app
77
messagingSenderId: '654372960384',
88
appId: '1:654372960384:web:198c257539512bf3470e65',
99
measurementId: 'G-EQMM08X1SE',
1010
},
11+
recaptchaSiteKey: '6Lc0LdgqAAAAAPkjqlF2O24bL7IVFJE7KQPxFcHl',
1112
};
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface SendEmailRequest {
2+
email: string;
3+
subject: string;
4+
message: string;
5+
}

src/services/email.service.spec.ts

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { TestBed } from '@angular/core/testing';
2+
3+
import { EmailService } from './email.service';
4+
5+
describe('EmailService', () => {
6+
let service: EmailService;
7+
8+
beforeEach(() => {
9+
TestBed.configureTestingModule({});
10+
service = TestBed.inject(EmailService);
11+
});
12+
13+
it('should be created', () => {
14+
expect(service).toBeTruthy();
15+
});
16+
});

0 commit comments

Comments
 (0)