diff --git a/amber/src/main/scala/org/apache/texera/web/resource/GmailResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/GmailResource.scala index f06f1f92102..ab91c9ad437 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/GmailResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/GmailResource.scala @@ -33,6 +33,7 @@ import javax.annotation.security.RolesAllowed import javax.mail.internet.{InternetAddress, MimeMessage} import javax.mail.{Message, PasswordAuthentication, Session, Transport} import javax.ws.rs._ +import javax.ws.rs.core.Response import scala.util.{Failure, Success, Try} case class EmailMessage( @@ -146,7 +147,13 @@ class GmailResource { @Path("/send") def sendEmailRequest(emailMessage: EmailMessage, @Auth user: SessionUser): Unit = { val recipientEmail = if (emailMessage.receiver.isEmpty) user.getEmail else emailMessage.receiver - sendEmail(emailMessage, recipientEmail) + sendEmail(emailMessage, recipientEmail) match { + case Right(_) => () + case Left("Invalid email format") => + throw new BadRequestException("Invalid email format") + case Left(error) => + throw new WebApplicationException(error, Response.Status.BAD_GATEWAY) + } } @GET diff --git a/amber/src/test/scala/org/apache/texera/web/resource/GmailResourceSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/GmailResourceSpec.scala new file mode 100644 index 00000000000..868b0e34cda --- /dev/null +++ b/amber/src/test/scala/org/apache/texera/web/resource/GmailResourceSpec.scala @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.texera.web.resource + +import org.apache.texera.auth.SessionUser +import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum +import org.apache.texera.dao.jooq.generated.tables.pojos.User +import org.scalatest.flatspec.AnyFlatSpec + +import javax.ws.rs.{BadRequestException, WebApplicationException} + +class GmailResourceSpec extends AnyFlatSpec { + + private def newSessionUser(): SessionUser = { + val user = new User + user.setUid(Integer.valueOf(1)) + user.setName("test") + user.setRole(UserRoleEnum.REGULAR) + user.setEmail("test@example.com") + new SessionUser(user) + } + + it should "throw BadRequestException (HTTP 400) when the receiver fails email-format validation" in { + val resource = new GmailResource() + val msg = EmailMessage( + receiver = "not-a-valid-email", + subject = "subj", + content = "body" + ) + val ex = intercept[BadRequestException] { + resource.sendEmailRequest(msg, newSessionUser()) + } + assert(ex.getResponse.getStatus == 400) + } + + it should "throw WebApplicationException with HTTP 502 when sendEmail fails for a non-validation reason" in { + // In the test environment `UserSystemConfig.gmail` defaults to "", so + // `createMimeMessage`'s `new InternetAddress(senderGmail)` raises an + // `AddressException` deterministically — without any network or SMTP + // server contact — and `sendEmail` returns `Left("Failed to send email: + // ...")`. The resource then maps that `Left` to a 502 BadGateway. + val resource = new GmailResource() + val msg = EmailMessage( + receiver = "valid@example.com", + subject = "subj", + content = "body" + ) + val ex = intercept[WebApplicationException] { + resource.sendEmailRequest(msg, newSessionUser()) + } + assert( + !ex.isInstanceOf[BadRequestException], + s"expected non-validation failure, but got BadRequestException: ${ex.getMessage}" + ) + assert(ex.getResponse.getStatus == 502) + } +} diff --git a/frontend/src/app/common/service/gmail/gmail.service.spec.ts b/frontend/src/app/common/service/gmail/gmail.service.spec.ts new file mode 100644 index 00000000000..fd4ce18ca72 --- /dev/null +++ b/frontend/src/app/common/service/gmail/gmail.service.spec.ts @@ -0,0 +1,66 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { TestBed } from "@angular/core/testing"; +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { GmailService } from "./gmail.service"; +import { NotificationService } from "../notification/notification.service"; + +describe("GmailService", () => { + let service: GmailService; + let httpTestingController: HttpTestingController; + let notificationSpy: { success: ReturnType; error: ReturnType }; + + beforeEach(() => { + notificationSpy = { success: vi.fn(), error: vi.fn() }; + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [GmailService, { provide: NotificationService, useValue: notificationSpy }], + }); + service = TestBed.inject(GmailService); + httpTestingController = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTestingController.verify(); + }); + + it("should show a success toast when the backend accepts the send request", () => { + service.sendEmail("subj", "body", "to@example.com"); + + const req = httpTestingController.expectOne(r => r.url.endsWith("/gmail/send") && r.method === "PUT"); + req.flush(null); + + expect(notificationSpy.success).toHaveBeenCalledWith("Email sent successfully"); + expect(notificationSpy.error).not.toHaveBeenCalled(); + }); + + it("should show an error toast when the backend returns an HTTP error (e.g. SMTP failure)", () => { + service.sendEmail("subj", "body", "to@example.com"); + + const req = httpTestingController.expectOne(r => r.url.endsWith("/gmail/send") && r.method === "PUT"); + req.flush("Failed to send email: 535-5.7.8 Username and Password not accepted", { + status: 502, + statusText: "Bad Gateway", + }); + + expect(notificationSpy.error).toHaveBeenCalledWith("Failed to send email. Please try again or contact admin."); + expect(notificationSpy.success).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/common/service/gmail/gmail.service.ts b/frontend/src/app/common/service/gmail/gmail.service.ts index 925e7e3a39d..3dfec89a064 100644 --- a/frontend/src/app/common/service/gmail/gmail.service.ts +++ b/frontend/src/app/common/service/gmail/gmail.service.ts @@ -42,7 +42,8 @@ export class GmailService { next: () => this.notificationService.success("Email sent successfully"), error: (error: unknown) => { if (error instanceof HttpErrorResponse) { - console.error(error.error); + this.notificationService.error("Failed to send email. Please try again or contact admin."); + console.error("Send email error:", error.error); } }, });