From eac3ea309fba60d73bd8f0e5f209d13d58d42ec9 Mon Sep 17 00:00:00 2001 From: Niko Pitkonen Date: Thu, 31 Aug 2023 09:53:59 +0300 Subject: [PATCH] HAI-1526 Add functions to send hanke and application invitation emails Add templates for hanke and application invitation emails. Add functionality to send these invitations in EmailSenderService. Note: It is possible for inviter name to be unknown. In these cases the emails use only the inviter email (foo.bar@mail.fi kutsui sinut..). Using the new email sending features will be implemented on a different upcoming pr. --- .../hanke/email/EmailSenderServiceITest.kt | 254 +++++++++++++++--- .../hanke/email/EmailSenderService.kt | 82 +++++- .../kayttaja-lisatty-hakemus.html.mustache | 38 +++ .../kayttaja-lisatty-hakemus.subject.mustache | 1 + .../kayttaja-lisatty-hakemus.text.mustache | 16 ++ .../kayttaja-lisatty-hanke.html.mustache | 35 +++ .../kayttaja-lisatty-hanke.subject.mustache | 1 + .../kayttaja-lisatty-hanke.text.mustache | 13 + 8 files changed, 394 insertions(+), 46 deletions(-) create mode 100644 services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hakemus.html.mustache create mode 100644 services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hakemus.subject.mustache create mode 100644 services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hakemus.text.mustache create mode 100644 services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hanke.html.mustache create mode 100644 services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hanke.subject.mustache create mode 100644 services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hanke.text.mustache diff --git a/services/hanke-service/src/integrationTest/kotlin/fi/hel/haitaton/hanke/email/EmailSenderServiceITest.kt b/services/hanke-service/src/integrationTest/kotlin/fi/hel/haitaton/hanke/email/EmailSenderServiceITest.kt index 7ac09604c..7b2b3e4a9 100644 --- a/services/hanke-service/src/integrationTest/kotlin/fi/hel/haitaton/hanke/email/EmailSenderServiceITest.kt +++ b/services/hanke-service/src/integrationTest/kotlin/fi/hel/haitaton/hanke/email/EmailSenderServiceITest.kt @@ -10,10 +10,13 @@ import assertk.assertions.startsWith import com.icegreen.greenmail.configuration.GreenMailConfiguration import com.icegreen.greenmail.junit5.GreenMailExtension import com.icegreen.greenmail.util.ServerSetupTest +import fi.hel.haitaton.hanke.ContactType import fi.hel.haitaton.hanke.DatabaseTest +import fi.hel.haitaton.hanke.application.ApplicationType import fi.hel.haitaton.hanke.firstReceivedMessage import jakarta.mail.internet.MimeMessage import jakarta.mail.internet.MimeMultipart +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension import org.springframework.beans.factory.annotation.Autowired @@ -22,7 +25,9 @@ import org.springframework.test.context.ActiveProfiles import org.testcontainers.junit.jupiter.Testcontainers private const val TEST_EMAIL = "test@test.test" +private const val HAITATON_NO_REPLY = "no-reply@hel.fi" private const val APPLICATION_IDENTIFIER = "JS2300001" +private const val DEFAULT_INVITER_NAME = "Kalle Kutsuja" @Testcontainers @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -39,54 +44,202 @@ class EmailSenderServiceITest : DatabaseTest() { @Autowired lateinit var emailSenderService: EmailSenderService - @Test - fun `sendJohtoselvitysCompleteEmail sends email with correct recipient`() { - emailSenderService.sendJohtoselvitysCompleteEmail(TEST_EMAIL, 13L, APPLICATION_IDENTIFIER) + @Nested + inner class JohtoSelvitysComplete { + @Test + fun `sendJohtoselvitysCompleteEmail sends email with correct recipient`() { + emailSenderService.sendJohtoselvitysCompleteEmail( + TEST_EMAIL, + 13L, + APPLICATION_IDENTIFIER + ) - val email = greenMail.firstReceivedMessage() - assertThat(email.allRecipients).hasSize(1) - assertThat(email.allRecipients[0].toString()).isEqualTo(TEST_EMAIL) - } + val email = greenMail.firstReceivedMessage() + assertThat(email.allRecipients).hasSize(1) + assertThat(email.allRecipients[0].toString()).isEqualTo(TEST_EMAIL) + } - @Test - fun `sendJohtoselvitysCompleteEmail sends email with sender from properties`() { - emailSenderService.sendJohtoselvitysCompleteEmail(TEST_EMAIL, 13L, APPLICATION_IDENTIFIER) + @Test + fun `sendJohtoselvitysCompleteEmail sends email with sender from properties`() { + emailSenderService.sendJohtoselvitysCompleteEmail( + TEST_EMAIL, + 13L, + APPLICATION_IDENTIFIER + ) - val email = greenMail.firstReceivedMessage() - assertThat(email.from).hasSize(1) - assertThat(email.from[0].toString()).isEqualTo("no-reply@hel.fi") - } + val email = greenMail.firstReceivedMessage() + assertThat(email.from).hasSize(1) + assertThat(email.from[0].toString()).isEqualTo(HAITATON_NO_REPLY) + } - @Test - fun `sendJohtoselvitysCompleteEmail sends email with correct subject`() { - emailSenderService.sendJohtoselvitysCompleteEmail(TEST_EMAIL, 13L, APPLICATION_IDENTIFIER) + @Test + fun `sendJohtoselvitysCompleteEmail sends email with correct subject`() { + emailSenderService.sendJohtoselvitysCompleteEmail( + TEST_EMAIL, + 13L, + APPLICATION_IDENTIFIER + ) + + val email = greenMail.firstReceivedMessage() + assertThat(email.subject) + .isEqualTo( + "Johtoselvitys JS2300001 / Ledningsutredning JS2300001 / Cable report JS2300001" + ) + } - val email = greenMail.firstReceivedMessage() - assertThat(email.subject) - .isEqualTo( - "Johtoselvitys JS2300001 / Ledningsutredning JS2300001 / Cable report JS2300001" + @Test + fun `sendJohtoselvitysCompleteEmail sends email with parametrized hybrid body`() { + emailSenderService.sendJohtoselvitysCompleteEmail( + TEST_EMAIL, + 13L, + APPLICATION_IDENTIFIER ) + + val email = greenMail.firstReceivedMessage() + val (textBody, htmlBody) = getBodiesFromHybridEmail(email) + assertThat(textBody).all { + contains(APPLICATION_IDENTIFIER) + contains("http://localhost:3001/fi/hakemus/13") + contains("http://localhost:3001/sv/ansokan/13") + contains("http://localhost:3001/en/application/13") + } + // Compress all whitespace into single spaces so that they don't interfere with + // matching. + val squashedHtmlBody = htmlBody.replace("\\s+".toRegex(), " ") + assertThat(squashedHtmlBody).all { + contains(APPLICATION_IDENTIFIER) + contains("""""") + contains("""""") + contains("""""") + } + } } - @Test - fun `sendJohtoselvitysCompleteEmail sends email with parametrized hybrid body`() { - emailSenderService.sendJohtoselvitysCompleteEmail(TEST_EMAIL, 13L, APPLICATION_IDENTIFIER) - - val email = greenMail.firstReceivedMessage() - val (textBody, htmlBody) = getBodiesFromHybridEmail(email) - assertThat(textBody).all { - contains(APPLICATION_IDENTIFIER) - contains("http://localhost:3001/fi/hakemus/13") - contains("http://localhost:3001/sv/ansokan/13") - contains("http://localhost:3001/en/application/13") + @Nested + inner class HankeInvitation { + + @Test + fun `sendHankeInvitationEmail sends email with correct recipient`() { + emailSenderService.sendHankeInvitationEmail(hankeInvitationData()) + + val email = greenMail.firstReceivedMessage() + assertThat(email.allRecipients).hasSize(1) + assertThat(email.allRecipients[0].toString()).isEqualTo(TEST_EMAIL) + } + + @Test + fun `sendHankeInvitationEmail sends email with sender from properties`() { + emailSenderService.sendHankeInvitationEmail(hankeInvitationData()) + + val email = greenMail.firstReceivedMessage() + assertThat(email.from).hasSize(1) + assertThat(email.from[0].toString()).isEqualTo(HAITATON_NO_REPLY) + } + + @Test + fun `sendHankeInvitationEmail sends email with correct subject`() { + emailSenderService.sendHankeInvitationEmail(hankeInvitationData()) + + val email = greenMail.firstReceivedMessage() + assertThat(email.subject).isEqualTo("Sinut on lisätty hankkeelle HAI24-1") } - // Compress all whitespace into single spaces so that they don't interfere with matching. - val squashedHtmlBody = htmlBody.replace("\\s+".toRegex(), " ") - assertThat(squashedHtmlBody).all { - contains(APPLICATION_IDENTIFIER) - contains("""""") - contains("""""") - contains("""""") + + @Test + fun `sendHankeInvitationEmail sends email with parametrized hybrid body`() { + val data = hankeInvitationData() + + emailSenderService.sendHankeInvitationEmail(data) + + val email = greenMail.firstReceivedMessage() + val (textBody, htmlBody) = getBodiesFromHybridEmail(email) + assertThat(textBody).all { + startsWith("${data.inviterName} (${data.inviterEmail}) lisäsi sinut") + contains("hankkeelle ${data.hankeNimi} (${data.hankeTunnus}).") + contains("http://localhost:3001/${data.invitationToken}") + } + assertThat(htmlBody).all { + contains("

${data.inviterName} (${data.inviterEmail}) lisäsi sinut") + contains("hankkeelle ${data.hankeNimi} (${data.hankeTunnus}).") + contains("""""") + } + } + + @Test + fun `sendHankeInvitationEmail handles input without inviter name`() { + val data = hankeInvitationData(inviterName = null) + + emailSenderService.sendHankeInvitationEmail(data) + + val email = greenMail.firstReceivedMessage() + val (textBody, htmlBody) = getBodiesFromHybridEmail(email) + assertThat(textBody).startsWith("Asioija ${data.inviterEmail} lisäsi sinut") + assertThat(htmlBody).contains("

Asioija ${data.inviterEmail} lisäsi sinut") + } + } + + @Nested + inner class ApplicationInvitation { + @Test + fun `sendApplicationInvitationEmail sends email with correct recipient`() { + emailSenderService.sendApplicationInvitationEmail(applicationInvitationData()) + + val email = greenMail.firstReceivedMessage() + assertThat(email.allRecipients).hasSize(1) + assertThat(email.allRecipients[0].toString()).isEqualTo(TEST_EMAIL) + } + + @Test + fun `sendApplicationInvitationEmail sends email with sender from properties`() { + emailSenderService.sendApplicationInvitationEmail(applicationInvitationData()) + + val email = greenMail.firstReceivedMessage() + assertThat(email.from).hasSize(1) + assertThat(email.from[0].toString()).isEqualTo(HAITATON_NO_REPLY) + } + + @Test + fun `sendApplicationInvitationEmail sends email with correct subject`() { + val data = applicationInvitationData() + emailSenderService.sendApplicationInvitationEmail(data) + + val email = greenMail.firstReceivedMessage() + assertThat(email.subject) + .isEqualTo("Sinut on lisätty hakemukselle ${data.applicationIdentifier}") + } + + @Test + fun `sendApplicationInvitationEmail sends email with parametrized hybrid body`() { + val data = applicationInvitationData() + + emailSenderService.sendApplicationInvitationEmail(data) + + val email = greenMail.firstReceivedMessage() + val (textBody, htmlBody) = getBodiesFromHybridEmail(email) + assertThat(textBody).all { + startsWith("${data.inviterName} (${data.inviterEmail}) on") + contains("tehnyt johtoselvityshakemuksen (${data.applicationIdentifier})") + contains("hankkeella ${data.hankeTunnus}") + contains("rooliin ${data.roleType.text()}.") + contains("Tarkastele hakemusta Haitattomassa: http://localhost:3001") + } + assertThat(htmlBody).all { + contains("${data.inviterName} (${data.inviterEmail})") + contains("johtoselvityshakemuksen (${data.applicationIdentifier})") + contains("rooliin ${data.roleType.text()}") + contains("""Tarkastele hakemusta Haitattomassa: """) + } + } + + @Test + fun `sendApplicationInvitationEmail handles input without inviter name`() { + val data = applicationInvitationData(inviterName = null) + + emailSenderService.sendApplicationInvitationEmail(data) + + val email = greenMail.firstReceivedMessage() + val (textBody, htmlBody) = getBodiesFromHybridEmail(email) + assertThat(textBody).startsWith("Asioija ${data.inviterEmail} on tehnyt") + assertThat(htmlBody).contains("

Asioija ${data.inviterEmail} on tehnyt") } } @@ -116,4 +269,27 @@ class EmailSenderServiceITest : DatabaseTest() { .map { i -> mp3.getBodyPart(i).content.toString() } return Pair(bodies[0], bodies[1]) } + + private fun hankeInvitationData(inviterName: String? = DEFAULT_INVITER_NAME) = + HankeInvitationData( + inviterName = inviterName, + inviterEmail = "kalle.kutsuja@test.fi", + recipientEmail = TEST_EMAIL, + hankeTunnus = "HAI24-1", + hankeNimi = "Mannerheimintien liikenneuudistus", + invitationToken = "MgtzRbcPsvoKQamnaSxCnmW7", + ) + + private fun applicationInvitationData(inviterName: String? = DEFAULT_INVITER_NAME) = + ApplicationInvitationData( + inviterName = inviterName, + inviterEmail = "kalle.kutsuja@test.fi", + recipientEmail = TEST_EMAIL, + applicationType = ApplicationType.CABLE_REPORT, + applicationIdentifier = APPLICATION_IDENTIFIER, + hankeTunnus = "HAI24-1", + roleType = ContactType.RAKENNUTTAJA, + ) + + private fun ContactType.text() = toString().lowercase() } diff --git a/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/email/EmailSenderService.kt b/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/email/EmailSenderService.kt index ad57777d4..4a748c8cf 100644 --- a/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/email/EmailSenderService.kt +++ b/services/hanke-service/src/main/kotlin/fi/hel/haitaton/hanke/email/EmailSenderService.kt @@ -1,5 +1,7 @@ package fi.hel.haitaton.hanke.email +import fi.hel.haitaton.hanke.ContactType +import fi.hel.haitaton.hanke.application.ApplicationType import fi.hel.haitaton.hanke.getResource import jakarta.mail.internet.MimeMessage import mu.KotlinLogging @@ -24,12 +26,36 @@ data class EmailFilterProperties( @Delimiter(";") val allowList: List, ) +data class ApplicationInvitationData( + val inviterName: String?, + val inviterEmail: String, + val recipientEmail: String, + val applicationType: ApplicationType, + val applicationIdentifier: String, + val hankeTunnus: String, + val roleType: ContactType, +) + +data class HankeInvitationData( + val inviterName: String?, + val inviterEmail: String, + val recipientEmail: String, + val hankeTunnus: String, + val hankeNimi: String, + val invitationToken: String, +) + +enum class EmailContext(val value: String) { + CABLE_REPORT_DONE("johtoselvitys-valmis"), + INVITATION_HANKE("kayttaja-lisatty-hanke"), + INVITATION_APPLICATION("kayttaja-lisatty-hakemus") +} + @Service class EmailSenderService( private val mailSender: JavaMailSender, private val emailConfig: EmailProperties, ) { - fun sendJohtoselvitysCompleteEmail( to: String, applicationId: Long?, @@ -42,18 +68,52 @@ class EmailSenderService( "applicationId" to applicationId.toString(), "applicationIdentifier" to applicationIdentifier, ) - sendHybridEmail(to, "johtoselvitys-valmis", templateData) + + sendHybridEmail(to, EmailContext.CABLE_REPORT_DONE, templateData) + } + + fun sendHankeInvitationEmail(data: HankeInvitationData) { + logger.info { "Sending invitation email for Hanke" } + + val templateData = + mapOf( + "baseUrl" to emailConfig.baseUrl, + "inviterInfo" to defineInviterInfo(data.inviterName, data.inviterEmail), + "hankeTunnus" to data.hankeTunnus, + "hankeNimi" to data.hankeNimi, + "invitationToken" to data.invitationToken, + ) + + sendHybridEmail(data.recipientEmail, EmailContext.INVITATION_HANKE, templateData) + } + + fun sendApplicationInvitationEmail(data: ApplicationInvitationData) { + logger.info { "Sending invitation email for application" } + + val applicationTypeText = convertApplicationTypeFinnish(data.applicationType) + + val templateData = + mapOf( + "baseUrl" to emailConfig.baseUrl, + "inviterInfo" to defineInviterInfo(data.inviterName, data.inviterEmail), + "applicationType" to applicationTypeText, + "applicationIdentifier" to data.applicationIdentifier, + "hankeTunnus" to data.hankeTunnus, + "recipientRole" to data.roleType.toString().lowercase(), + ) + + sendHybridEmail(data.recipientEmail, EmailContext.INVITATION_APPLICATION, templateData) } - private fun sendHybridEmail(to: String, template: String, templateData: Map) { + private fun sendHybridEmail(to: String, context: EmailContext, data: Map) { if (emailConfig.filter.use && emailNotAllowed(to)) { logger.info { "Email recipient not allowed, ignoring email." } return } - val textBody = parseTemplate("/email/template/$template.text.mustache", templateData) - val htmlBody = parseTemplate("/email/template/$template.html.mustache", templateData) - val subject = - parseTemplate("/email/template/$template.subject.mustache", templateData).trimEnd() + val basePath = "/email/template" + val textBody = parseTemplate("$basePath/${context.value}.text.mustache", data) + val htmlBody = parseTemplate("$basePath/${context.value}.html.mustache", data) + val subject = parseTemplate("$basePath/${context.value}.subject.mustache", data).trimEnd() val mimeMessage: MimeMessage = mailSender.createMimeMessage() val helper = MimeMessageHelper(mimeMessage, true, "utf-8") @@ -65,6 +125,14 @@ class EmailSenderService( mailSender.send(mimeMessage) } + private fun defineInviterInfo(name: String?, email: String): String = + if (name.isNullOrBlank()) "Asioija $email" else "$name ($email)" + + private fun convertApplicationTypeFinnish(type: ApplicationType): String = + when (type) { + ApplicationType.CABLE_REPORT -> "johtoselvityshakemuksen" + } + private fun emailNotAllowed(email: String) = !emailConfig.filter.allowList.contains(email) private fun parseTemplate(path: String, contextObject: Any): String = diff --git a/services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hakemus.html.mustache b/services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hakemus.html.mustache new file mode 100644 index 000000000..01a52f954 --- /dev/null +++ b/services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hakemus.html.mustache @@ -0,0 +1,38 @@ + + + + +

{{inviterInfo}} on tehnyt {{applicationType}} ({{applicationIdentifier}}) + hankkeella {{hankeTunnus}}, ja lähettänyt sen käsittelyyn. Sinut on määritetty hakemuksella + rooliin {{recipientRole}}. + Tarkastele hakemusta Haitattomassa: {{baseUrl}} +

+ +

Löydät hakemuksesi siirtymällä etusivulta Omiin hankkeisiin ja valitsemalla sieltä oikean hankkeen. Avaa + listanäkymässä + hankkeen tiedot ja valitse “Näytä hankkeen hakemukset”.

+ +

Haitaton on Helsingin kaupungin asiointijärjestelmä yleisille alueille sijoittuvien hankkeiden edistämiseen ja + haittojen hallintaan.

+ +

Tämä on automaattinen sähköposti – älä vastaa tähän viestiin.

+ +

+ Ystävällisin terveisin,
+ Helsingin kaupungin kaupunkiympäristön toimiala
+ Haitaton-asiointi
+ haitaton@hel.fi +

+ +

+ +

+ + + diff --git a/services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hakemus.subject.mustache b/services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hakemus.subject.mustache new file mode 100644 index 000000000..849b678c6 --- /dev/null +++ b/services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hakemus.subject.mustache @@ -0,0 +1 @@ +Sinut on lisätty hakemukselle {{applicationIdentifier}} diff --git a/services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hakemus.text.mustache b/services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hakemus.text.mustache new file mode 100644 index 000000000..3b27d2a9b --- /dev/null +++ b/services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hakemus.text.mustache @@ -0,0 +1,16 @@ +{{inviterInfo}} on tehnyt {{applicationType}} ({{applicationIdentifier}}) hankkeella {{hankeTunnus}}, ja lähettänyt sen käsittelyyn. Sinut on määritetty hakemuksella rooliin {{recipientRole}}. Tarkastele hakemusta Haitattomassa: {{baseUrl}} + +Löydät hakemuksesi siirtymällä etusivulta Omiin hankkeisiin ja valitsemalla sieltä oikean hankkeen. Avaa listanäkymässä hankkeen tiedot ja valitse “Näytä hankkeen hakemukset”. + +Haitaton on Helsingin kaupungin asiointijärjestelmä yleisille alueille sijoittuvien hankkeiden edistämiseen ja haittojen hallintaan. + +Tämä on automaattinen sähköposti – älä vastaa tähän viestiin. + + +Ystävällisin terveisin, + +Helsingin kaupungin kaupunkiympäristön toimiala + +Haitaton-asiointi + +haitaton@hel.fi diff --git a/services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hanke.html.mustache b/services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hanke.html.mustache new file mode 100644 index 000000000..2b3e89b96 --- /dev/null +++ b/services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hanke.html.mustache @@ -0,0 +1,35 @@ + + + + +

{{inviterInfo}} lisäsi sinut hankkeelle {{hankeNimi}} ({{hankeTunnus}}). Tunnistaudu + Haitattomaan + alla olevan linkin kautta.
+ {{baseUrl}}/{{invitationToken}} +

+ +

Löydät hankkeesi Haitattoman etusivun Omat hankkeet -osion alta hankkeen nimellä.

+ +

Haitaton on Helsingin kaupungin asiointijärjestelmä yleisille alueille sijoittuvien hankkeiden edistämiseen ja + haittojen hallintaan.

+ +

Tämä on automaattinen sähköposti – älä vastaa tähän viestiin.

+ +

+ Ystävällisin terveisin,
+ Helsingin kaupungin kaupunkiympäristön toimiala
+ Haitaton-asiointi
+ haitaton@hel.fi +

+ +

+ +

+ + diff --git a/services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hanke.subject.mustache b/services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hanke.subject.mustache new file mode 100644 index 000000000..75becad00 --- /dev/null +++ b/services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hanke.subject.mustache @@ -0,0 +1 @@ +Sinut on lisätty hankkeelle {{hankeTunnus}} diff --git a/services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hanke.text.mustache b/services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hanke.text.mustache new file mode 100644 index 000000000..244abc486 --- /dev/null +++ b/services/hanke-service/src/main/resources/email/template/kayttaja-lisatty-hanke.text.mustache @@ -0,0 +1,13 @@ +{{inviterInfo}} lisäsi sinut hankkeelle {{hankeNimi}} ({{hankeTunnus}}). Tunnistaudu Haitattomaan alla olevan linkin kautta. +{{baseUrl}}/{{invitationToken}} + +Löydät hankkeesi Haitattoman etusivun Omat hankkeet -osion alta hankkeen nimellä. + +Haitaton on Helsingin kaupungin asiointijärjestelmä yleisille alueille sijoittuvien hankkeiden edistämiseen ja haittojen hallintaan. + +Tämä on automaattinen sähköposti – älä vastaa tähän viestiin. + +Ystävällisin terveisin, +Helsingin kaupungin kaupunkiympäristön toimiala +Haitaton-asiointi +haitaton@hel.fi