Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(_) => ()
Comment thread
mengw15 marked this conversation as resolved.
case Left("Invalid email format") =>
throw new BadRequestException("Invalid email format")
case Left(error) =>
throw new WebApplicationException(error, Response.Status.BAD_GATEWAY)
}
Comment thread
mengw15 marked this conversation as resolved.
}

@GET
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
66 changes: 66 additions & 0 deletions frontend/src/app/common/service/gmail/gmail.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>; error: ReturnType<typeof vi.fn> };

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();
});
});
3 changes: 2 additions & 1 deletion frontend/src/app/common/service/gmail/gmail.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
},
});
Expand Down
Loading